From ef140a54e29712cea75a4a714e4011030663f12c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 19:08:24 -0400 Subject: [PATCH 01/43] =?UTF-8?q?docs:=20pro=C3=ADbe=20atribui=C3=A7=C3=B5?= =?UTF-8?q?es=20de=20IA=20em=20commits=20e=20descri=C3=A7=C3=B5es=20de=20P?= =?UTF-8?q?R?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3af7923e..4af7250d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ Product/architecture source of truth (PRD, UX flows, LGPD design, stack decision - CI lives in [.github/workflows/ci.yml](.github/workflows/ci.yml) and runs on every PR, on pushes to `main`/`develop`, and by hand (`gh workflow run CI --ref `). 9 jobs: `workflow-lint`, `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (the only one that boots a real emulator against the stack), `admin-android-build`. `workflow-lint` runs actionlint plus `scripts/qa/ci_invariants.sh`, which fails when: this job list drifts from `JOBS_DOCUMENTADOS`; a job leaves the pinned runner (`RUNNER = 'ubuntu-24.04'`, never `ubuntu-latest`); an action drops below its node24 major (`checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7`); the workflow gains a `paths` filter or loses the per-PR/per-SHA `concurrency` group; `android-e2e` loses its `pg_data/` cleanup step; or the AVD cache key does not end in `-`. `workflow-lint` also runs `scripts/ci/decode_secret_file_test.sh`, `scripts/qa/ci_push_e2e_test.sh` and `scripts/qa/ci_invariants_fcm_test.sh`, and `ci_invariants.sh` additionally fails when the secrets `FCM_CREDENTIALS_BASE64`/`GOOGLE_SERVICES_JSON_BASE64` appear inside a `run:` or in the job-level `env:`, when a decode step comes after the E2E step, when the `if: always()` cleanup step is missing, when the emulator loses `target: google_apis` (Play Services; the action's default image is AOSP and FCM returns no token) or when the AVD cache key does not contain it. `android-e2e` decodes both secrets (service-account key to a temp file + `GOOGLE_APPLICATION_CREDENTIALS`, `google-services.json` into `apps/patient/android/app/`) and, when they exist, ends with `scripts/qa/ci_push_e2e.sh` (real Gorush and FCM); without them (fork PRs) it skips. - `main` and `develop` are protected: the 8 checks from `./scripts/qa/ci_invariants.sh --checks-obrigatorios` (every job except `android-e2e`, tied to GitHub Actions app 15368) must pass to merge, and `main` also requires a PR; admins can still push directly. `android-e2e` has been green since the fixes of 2026-09-28 but stays informational until it builds a longer history. The script does not read the live protection: after renaming or adding a job, re-apply it (see `CONTRIBUTING.md` › CI e merge) or PRs wait forever for a check that no longer exists. Moving to Ubuntu 26.04 (`ubuntu-latest` migrates on 2026-10-19; a rehearsal ran 9/9 green) is a PR that changes `runs-on`, `RUNNER` and the AVD key suffix together, and needs an actionlint that knows the `ubuntu-26.04` label. History in `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`; known gaps in the guard are issues #19–#21, #23–#24 (#22 fixed — `on:` as a string/list is normalized instead of crashing, and an unrecognized flag exits 2 with a usage message instead of silently exiting 0). - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. +- Never include AI attributions, "Co-Authored-By", or "Generated with Claude Code" labels in git commits or PR descriptions. ## graphify From 95f05081d887edf0f8d0fed3c495646edbe1fcf1 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 19:25:35 -0400 Subject: [PATCH 02/43] =?UTF-8?q?docs:=20plano=20de=20finaliza=C3=A7=C3=A3?= =?UTF-8?q?o=20do=20app=20ACS=20(localiza=C3=A7=C3=A3o,=20SAMU,=20e2e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-01-finalizacao-app-acs.md | 1026 +++++++++++++++++ 1 file changed, 1026 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md diff --git a/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md b/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md new file mode 100644 index 00000000..ae54a01b --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md @@ -0,0 +1,1026 @@ +# Finalização do app ACS — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar o que falta para o app `apps/acs` fazer, num aparelho de verdade, o que o PRD promete — GPS do check-in (RF12), acionamento do SAMU (RF13) — e provar a jornada inteira do ACS no `emulator-5554` com login real contra o banco de teste. + +**Architecture:** Quatro correções independentes, cada uma com teste que falha antes: (1) manifesto Android declara o que o código já usa; (2) botão do SAMU abre o discador por uma interface injetável; (3) o caminho de GPS real do check-in é provado no emulador com `adb emu geo fix`; (4) um e2e do ACS no molde do `patient_full_e2e.sh` (banco `sinalacs_e2e`, fixtures sintéticas, login institucional pela tela). O resto é documentação e a barra final. + +**Tech Stack:** Flutter 3 / Dart, `geolocator`, `url_launcher` (nomeado no PRD, RF13), `integration_test`, Docker Compose (`docker-compose.e2e.yml`), bash, Python 3 (`otp_relay.py`), `adb`. + +**Spec:** `spec/PRD_system.md` (RF07–RF13, §2.2), `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4 geofencing: só em primeiro plano, sem localização em background), `spec/lgpd_design.md`, `apps/CLAUDE.md`. + +## Estado verificado em 2026-10-01 + +Medido nesta sessão, não herdado de documento: + +- `flutter analyze` em `apps/acs`: **No issues found**. `flutter test`: **198 passam**. +- `emulator-5554` **não estava no ar** (`adb devices` vazio; `adb` não está no `PATH`). AVDs existentes: `Medium_Phone`, `Medium_Tablet`. Foi iniciado com `emulator -avd Medium_Phone -port 5554 -no-snapshot-save -no-boot-anim`. +- Stack de desenvolvimento subida (`docker compose up --build -d`, `database-seed`, `sync_dev_ca.sh`). Bateria `integration_test` do ACS no emulador, com os `--dart-define` de `e2e.sh`: **15 testes passam** (`smoke`, `red_alert_cycle`, `map_flow`, `encrypted_storage`). Chave do Google Maps presente no `.env`. +- **Achado 1 (defeito real, RF12):** `aapt2 dump permissions` nos APKs de debug **e** de release do ACS não mostra `ACCESS_FINE_LOCATION` nem `ACCESS_COARSE_LOCATION`. O `AndroidManifest.xml` `main` não declara nenhuma `uses-permission`. Sem elas, `Geolocator.requestPermission()` não pode mostrar o diálogo e `_loadCurrentPosition` (`apps/acs/lib/app/app.dart:549`) engole o erro num `catch (_)`: `_currentPosition` fica `null` para sempre e o check-in passivo nunca chega a "Local alcançado". Os testes atuais passam porque **injetam** `currentPosition`/`initialPosition`; nenhum exercita o GPS do aparelho. O documento de decisão §4 diz que a permissão "declarada no `AndroidManifest.xml`" existe — não existe. +- **Achado 2 (hipótese minha que a medição desmentiu):** `INTERNET` **está** no APK de release (vem de uma dependência transitiva, não do manifesto do app). Não é defeito; a Task 1 a declara explicitamente só para não depender de uma dependência. +- **Achado 3 (RF13, L-07):** `EscalationScreen` (`app.dart:1915`) ainda mostra `'Discagem não está integrada neste protótipo.'` no botão do SAMU e `'Encaminhamento será integrado à UBS.'` no da UBS. `url_launcher` não está no `pubspec.yaml`. +- **Achado 4 (lacuna de teste):** todos os `integration_test/` e `tool/` do ACS usam `developmentLogin(role: 'acs')`. O paciente tem jornada completa com login real no banco de teste (`scripts/qa/patient_full_e2e.sh`); o ACS não tem equivalente — o login institucional (RF07) e o fluxo pela tela nunca rodam no emulador. +- **Achado 5 (fora do ACS, a verificar):** o manifesto `main` do paciente também não tem `uses-permission` de `INTERNET`; como o ACS, deve vir de dependência. Não mexer aqui; só registrar (Task 5). + +## Fora deste plano (e por quê) + +| Item | Motivo | +|---|---| +| MFA/TOTP e refresh token do ACS | Adiados de propósito (`PROGRESS.md`, `spec/security_assessment.md` F5). | +| RF08 — cache persistido da microárea (offline) | Guardar nome e condições crônicas de pacientes no aparelho exige decisão de LGPD (minimização, SQLCipher, expurgo) e é uma feature do tamanho de um plano próprio. **Recomendo um plano separado**, depois de decidido o desenho em `spec/lgpd_design.md`. | +| Botão "Encaminhar para UBS Central" | Não há fonte de contato da UBS (telefone/URL) nem no modelo de dados nem no envelope MQTT. É decisão de produto; a Task 2 deixa o botão exatamente como está e o diz na tela de documentação. | +| iOS | `apps/acs/ios/` não existe. | +| Restrição/cobrança da chave do Google Maps | Operacional, fora do código. | + +## Global Constraints + +- Nenhum dado real de paciente em teste, log, captura ou config; CPFs/nomes só sintéticos. `integration_test` novos usam UUIDs/fixtures sintéticos. +- Triagem determinística e alerta vermelho nunca descartado/atrasado: nenhuma tarefa toca `triage.evaluate`, `AlertQueue` ou a fila offline além de ler. +- **Sem localização em segundo plano** (decisão §4): `ACCESS_BACKGROUND_LOCATION` não pode aparecer no manifesto (teste da Task 1 garante). +- O botão do SAMU **abre o discador, nunca liga sozinho** (`ACTION_DIAL`; sem `CALL_PHONE`). Alvo de toque ≥ `64x60` dp (já é; não regredir — `spec/ux_accessibility_assessment.md`). +- Cor clínica usada como texto/ícone usa o token `*OnSurface` (`apps/CLAUDE.md`). +- Textos de UI, comentários e commits em português. +- **Commits sem qualquer atribuição de IA** (`Co-Authored-By`, "Generated with Claude Code"): regra adicionada ao `CLAUDE.md` em `ef140a5`. Isto substitui o rodapé `Co-Authored-By` dos planos anteriores. Sem push nem merge. +- `flutter analyze` em `apps/acs` sem problemas antes de cada commit; baseline `flutter test`: 198 (só pode crescer). +- O manifesto `.e2e/fixtures.json` tem a senha do ACS: gitignorado, `chmod 600`, apagado ao final. +- `adb`/`flutter`/`serverpod` não estão no `PATH` desta máquina: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$HOME/flutter/bin:$HOME/.pub-cache/bin"`. + +## Review Focus + +As entradas e condições que a spec implica, nenhuma tarefa exercitaria sozinha e mais provavelmente morderiam quem usa o app. Cada linha tem o teste que a fixa na tarefa dona: + +1. **Permissão de localização negada** (ACS toca "Negar"): o check-in mostra "Localização atual indisponível…" e o registro de visita continua disponível pelo caminho manual — nunca trava o ACS. → teste na Task 3. +2. **Sem discador no aparelho** (`launchUrl` devolve `false` ou lança): o botão do SAMU mostra o número em texto ("Ligue manualmente para 192"), nunca falha em silêncio. É o botão mais crítico da UI. → teste na Task 2. +3. **Toque duplo no SAMU:** abrir o discador duas vezes não pode empilhar duas telas de discagem. → teste na Task 2. +4. **Senha errada no login institucional:** erro visível (`login_error`) e o painel **não** abre; a sessão certa logo depois funciona sem reiniciar. → teste na Task 4. +5. **Paciente de outra microárea** não aparece no seletor de visita do ACS (RBAC por território, RNF06). → teste na Task 4. + +--- + +### Task 1: Declarar a localização no manifesto Android + +**Files:** +- Create: `apps/acs/test/android_manifest_test.dart` +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml:1-2` (acrescentar `uses-permission` antes de ` RegExp( + '', + ).hasMatch(manifest); + + test('declara localização precisa e aproximada (RF12)', () { + expect(declares('ACCESS_FINE_LOCATION'), isTrue); + expect(declares('ACCESS_COARSE_LOCATION'), isTrue); + }); + + test('declara INTERNET no manifesto de release, sem depender de plugin', () { + expect(declares('INTERNET'), isTrue); + }); + + test('NÃO pede localização em segundo plano (decisão §4 de 2026-09-16)', () { + expect(declares('ACCESS_BACKGROUND_LOCATION'), isFalse, + reason: 'o geofence é só em primeiro plano; background exigiria revisão da loja e LGPD'); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart` +Expected: FAIL — `declara localização precisa e aproximada` e `declara INTERNET…` com `Expected: true Actual: `; o terceiro passa. + +- [ ] **Step 3: Implementação mínima** + +Em `apps/acs/android/app/src/main/AndroidManifest.xml`, logo depois de `` e antes de ` + + + +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart` +Expected: PASS (3 testes). + +- [ ] **Step 5: Provar no APK real (debug e release)** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +cd apps/acs +flutter build apk --debug +flutter build apk --release --android-project-arg=sinalacs.allowMissingMqttPassword=true +AAPT="$HOME/Android/Sdk/build-tools/36.0.0/aapt2" +for apk in debug release; do echo "== $apk"; "$AAPT" dump permissions build/app/outputs/flutter-apk/app-$apk.apk | grep -E "LOCATION|INTERNET"; done +``` +Expected: nos dois, `ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION` e `INTERNET`; **nenhum** `ACCESS_BACKGROUND_LOCATION`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/test/android_manifest_test.dart apps/acs/android/app/src/main/AndroidManifest.xml +git commit -m "fix(acs): declara localização e INTERNET no manifesto de release (RF12)" +``` + +--- + +### Task 2: Acionar o SAMU pelo discador (RF13 / L-07) + +**Files:** +- Create: `apps/acs/lib/core/services/emergency_dialer.dart` +- Create: `apps/acs/test/escalation_dialer_test.dart` +- Modify: `apps/acs/pubspec.yaml` (dependência `url_launcher`) +- Modify: `apps/acs/lib/app/app.dart:1915-1962` (`EscalationScreen`) +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml` (``) +- Modify: `apps/acs/test/android_manifest_test.dart` (um teste) +- Modify: `apps/acs/integration_test/map_flow_test.dart` (um teste) + +**Interfaces:** +- Consumes: `declares(...)` e `manifest` da Task 1; `_message(BuildContext, String)` de `app.dart:2224`; `testAlert(...)` e `seedMicroAreaId` de `test/support/fakes.dart`. +- Produces: `const samuNumber = '192'`; `abstract interface class EmergencyDialer { Future dial(String number); }`; `class UrlLauncherEmergencyDialer implements EmergencyDialer` (const); `EscalationScreen({alert, onVisit, EmergencyDialer dialer = const UrlLauncherEmergencyDialer()})`. + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `apps/acs/test/escalation_dialer_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; + +import 'support/fakes.dart'; + +class _FakeDialer implements EmergencyDialer { + _FakeDialer({this.opens = true, this.throws = false}); + + final bool opens; + final bool throws; + final dialed = []; + + @override + Future dial(String number) async { + dialed.add(number); + if (throws) throw StateError('sem discador'); + return opens; + } +} + +Future _pump(WidgetTester tester, _FakeDialer dialer) => tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + )); + +void main() { + testWidgets('o botão do SAMU abre o discador com 192', (tester) async { + final dialer = _FakeDialer(); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.dialed, ['192']); + expect(find.text('Discagem não está integrada neste protótipo.'), findsNothing); + }); + + testWidgets('sem discador, mostra o número em texto em vez de falhar em silêncio', (tester) async { + final dialer = _FakeDialer(opens: false); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('um erro do discador cai no mesmo aviso, sem derrubar a tela', (tester) async { + final dialer = _FakeDialer(throws: true); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('toque duplo não abre dois discadores', (tester) async { + final dialer = _FakeDialer(); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.dialed, ['192']); + }); + + testWidgets('o botão da UBS continua sendo o aviso honesto (sem contato cadastrado)', (tester) async { + await _pump(tester, _FakeDialer()); + + await tester.tap(find.text('Encaminhar para UBS Central')); + await tester.pump(); + + expect(find.text('Encaminhamento será integrado à UBS.'), findsOneWidget); + }); +} +``` + +Acrescentar a `apps/acs/test/android_manifest_test.dart`, dentro de `main()`: + +```dart + test('declara a consulta ao discador (tel:) para o botão do SAMU (RF13)', () { + // Android 11+: sem , `canLaunchUrl(tel:)` devolve false mesmo com discador. + expect( + RegExp(r'\s*') + .hasMatch(manifest), + isTrue, + ); + }); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/escalation_dialer_test.dart test/android_manifest_test.dart` +Expected: FAIL — `escalation_dialer_test.dart` não compila (`emergency_dialer.dart` não existe) e o teste de `tel:` falha. + +- [ ] **Step 3: Dependência e interface** + +Em `apps/acs/pubspec.yaml`, em `dependencies:` (junto de `qr_flutter`): + +```yaml + # RF13: abre o discador do aparelho (tel:192). O PRD já a nomeia na matriz + # de capacidades; é invólucro de intent do sistema, não framework novo. + url_launcher: ^6.3.0 +``` + +Run: `cd apps/acs && flutter pub get` + +Criar `apps/acs/lib/core/services/emergency_dialer.dart`: + +```dart +import 'package:url_launcher/url_launcher.dart'; + +/// Número do SAMU. Constante de domínio (PRD, RF13), não configuração. +const samuNumber = '192'; + +/// Abre o discador do aparelho com o número já digitado. +/// +/// **Nunca liga sozinho:** `tel:` abre o discador e a pessoa confirma a +/// chamada. É o que dispensa `CALL_PHONE` no manifesto (permissão sensível, +/// revisada pela loja) e evita uma ligação por toque acidental. +abstract interface class EmergencyDialer { + /// `true` se o discador foi aberto; `false` se o aparelho não tem como. + Future dial(String number); +} + +class UrlLauncherEmergencyDialer implements EmergencyDialer { + const UrlLauncherEmergencyDialer(); + + @override + Future dial(String number) async { + try { + return await launchUrl(Uri(scheme: 'tel', path: number)); + } catch (_) { + // Qualquer falha vira "não abriu": quem chama mostra o número em texto. + return false; + } + } +} +``` + +- [ ] **Step 4: Ligar na tela** + +Em `apps/acs/lib/app/app.dart`, `import 'package:sinalacs_acs/core/services/emergency_dialer.dart';` junto dos outros imports de `core/services`. Trocar a classe `EscalationScreen` (`StatelessWidget` → `StatefulWidget`, para guardar o "já abri"): + +```dart +class EscalationScreen extends StatefulWidget { + const EscalationScreen({ + super.key, + this.alert, + this.onVisit, + this.dialer = const UrlLauncherEmergencyDialer(), + }); + + final PrioritizedAlert? alert; + final void Function(PrioritizedAlert alert)? onVisit; + final EmergencyDialer dialer; + + @override + State createState() => _EscalationScreenState(); +} + +class _EscalationScreenState extends State { + /// Trava o toque duplo: dois `launchUrl` seguidos empilham dois discadores. + bool _dialing = false; + + Future _callSamu() async { + if (_dialing) return; + setState(() => _dialing = true); + var opened = false; + try { + opened = await widget.dialer.dial(samuNumber); + } catch (_) { + opened = false; + } + if (!mounted) return; + setState(() => _dialing = false); + if (!opened) { + _message(context, 'Não foi possível abrir o discador. Ligue manualmente para $samuNumber.'); + } + } + + @override + Widget build(BuildContext context) { + final current = widget.alert; + final onVisit = widget.onVisit; + // ... corpo atual de build(), com duas trocas: + // 1. `onPressed: () => _message(context, 'Discagem não está integrada neste protótipo.')` + // vira `onPressed: _dialing ? null : _callSamu` + // 2. `onPressed: onVisit == null ? null : () => onVisit!(current)` vira + // `onPressed: onVisit == null ? null : () => onVisit(current)` + } +} +``` + +(O resto do `build` — `_InfoRow`, botão da UBS com `'Encaminhamento será integrado à UBS.'`, `escalation_visit`, o texto "A visita é acompanhamento do caso…" — fica **igual**, só trocando `alert`/`onVisit` por `current`/`onVisit` locais como acima. `minimumSize: const Size(64, 60)` do SAMU não muda.) + +Em `AndroidManifest.xml`, dentro de ``: + +```xml + + + + +``` + +- [ ] **Step 5: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/escalation_dialer_test.dart test/android_manifest_test.dart test/login_flow_test.dart && flutter analyze` +Expected: PASS (os testes existentes de `EscalationScreen` em `login_flow_test.dart:775` continuam verdes) e `No issues found`. + +- [ ] **Step 6: Provar no emulador que o discador resolve** + +Acrescentar a `apps/acs/integration_test/map_flow_test.dart`, dentro de `main()`: + +```dart + testWidgets('o discador do aparelho resolve tel:192 (RF13)', (tester) async { + // Prova o que o widget test não alcança: declarada e um + // handler de `tel:` presente no aparelho. Não abre a chamada. + final available = await canLaunchUrl(Uri(scheme: 'tel', path: '192')); + expect(available, isTrue, + reason: 'sem para tel: (Android 11+) ou sem discador no emulador'); + }); +``` +(import `package:url_launcher/url_launcher.dart`.) + +Run (stack de dev de pé, emulador no ar — ver Task 6 para o preparo): +`cd apps/acs && flutter test integration_test/map_flow_test.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=SINALACS_MQTT_HOST=localhost --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD"` +Expected: PASS. **Se `false`:** `adb -s emulator-5554 shell pm resolve-activity -a android.intent.action.DIAL -d tel:192` — vazio quer dizer imagem de emulador sem discador (registrar, não é defeito do app). + +- [ ] **Step 7: Commit** + +```bash +git add apps/acs/pubspec.yaml apps/acs/pubspec.lock apps/acs/lib/core/services/emergency_dialer.dart apps/acs/lib/app/app.dart apps/acs/android/app/src/main/AndroidManifest.xml apps/acs/test/escalation_dialer_test.dart apps/acs/test/android_manifest_test.dart apps/acs/integration_test/map_flow_test.dart +git commit -m "feat(acs): botão do SAMU abre o discador com 192 (RF13, L-07)" +``` + +--- + +### Task 3: Provar o GPS real do check-in no emulador (RF12) + +**Files:** +- Create: `apps/acs/integration_test/geofence_gps_e2e.dart` +- Create: `scripts/qa/acs_gps_e2e.sh` + +**Interfaces:** +- Consumes: manifesto da Task 1; `SinalAcsApp`, `FakeAcsBackend`, `FakeAlertFeed`, `testAlert`, `seedMicroAreaId`, `testLocationCell` (`test/support/fakes.dart`); chaves `matricula_field`, `senha_field`, `login_button`, `geofence_status`. +- Produces: `scripts/qa/acs_gps_e2e.sh [--sem-permissao]` (exit 0 = passou). + +O diálogo de permissão é do sistema: o `integration_test` não o toca. Então o script instala o APK **já com a permissão concedida** (`adb install -g`) e posiciona o GPS do emulador (`adb emu geo fix `). A célula `-1580:-4783` tem centro em lat `-15.795`, lon `-47.825` e o raio de chegada é 150 m (`RouteService.arrivalThresholdKm = 0.15`). + +- [ ] **Step 1: Descobrir se `flutter test` aceita APK pronto (decide o mecanismo)** + +Run: `cd apps/acs && flutter test --help | grep -i "use-application-binary"` +Expected: uma linha com `--use-application-binary`. Se **não** houver, usar o caminho B do Step 4. + +- [ ] **Step 2: Escrever o teste que falha** + +Criar `apps/acs/integration_test/geofence_gps_e2e.dart`: + +```dart +/// RF12 com o GPS do aparelho, não com posição injetada. Pré-requisitos e +/// cenário em `scripts/qa/acs_gps_e2e.sh` (concede/nega a permissão e fixa a +/// posição do emulador antes de rodar). `--dart-define=EXPECT_PERMISSION=granted|denied`. +@Timeout(Duration(minutes: 2)) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import '../test/support/fakes.dart'; + +const _expect = String.fromEnvironment('EXPECT_PERMISSION', defaultValue: 'granted'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('o GPS do aparelho decide o check-in passivo ($_expect)', (tester) async { + final alert = testAlert(alertId: 'gps-1', locationCell: testLocationCell); + final backend = FakeAcsBackend(); + + await tester.pumpWidget(SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + initialAlert: alert, + // `currentPosition` AUSENTE de propósito: a posição tem de vir do Geolocator. + )); + await tester.enterText(find.byKey(const Key('matricula_field')), '123456'); + await tester.enterText(find.byKey(const Key('senha_field')), 'qualquer'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpFor(tester, const Duration(seconds: 3)); + + await tester.tap(find.text('Mais')); + await _pumpFor(tester, const Duration(milliseconds: 500)); + await tester.tap(find.text('Geofencing')); + // O GPS leva alguns segundos para o primeiro fix no emulador. + await _pumpUntil( + tester, + () => _expect == 'granted' + ? find.text('Local alcançado. O registro da visita está liberado.').evaluate().isNotEmpty + : find.textContaining('Localização atual indisponível').evaluate().isNotEmpty, + timeout: const Duration(seconds: 30), + ); + + if (_expect == 'granted') { + expect(find.text('Local alcançado. O registro da visita está liberado.'), findsOneWidget); + expect(tester.widget(find.byKey(const Key('geofence_visit'))).onPressed, isNotNull); + } else { + // Review Focus 1: permissão negada nunca trava o ACS. + expect(find.textContaining('Localização atual indisponível'), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); +} + +Future _pumpFor(WidgetTester tester, Duration d) async { + final fim = DateTime.now().add(d); + while (DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +Future _pumpUntil(WidgetTester tester, bool Function() done, {required Duration timeout}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 200)); + } +} +``` + +Run (sem o script, permissão ainda não concedida, deve falhar): +`cd apps/acs && flutter test integration_test/geofence_gps_e2e.dart -d emulator-5554` +Expected: FAIL por timeout — a posição nunca chega (sem permissão concedida, e o diálogo do sistema fica sem ninguém para tocar). + +- [ ] **Step 3: Criar o script** + +Criar `scripts/qa/acs_gps_e2e.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# +# GPS real do check-in do ACS (RF12) no emulador-5554. +# +# ./scripts/qa/acs_gps_e2e.sh # permissão concedida: "Local alcançado" +# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # permissão negada: "indisponível", sem travar +# +# O diálogo de permissão é do sistema e o integration_test não o toca: o APK é +# instalado já com a permissão concedida (ou negada) por `pm`, e a posição do +# emulador é fixada por `adb emu geo fix` no centro da célula `-1580:-4783` +# (lat -15.795, lon -47.825), a 0 m do destino (raio de chegada: 150 m). +# Não precisa da stack: o teste usa FakeAcsBackend. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root/apps/acs" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +expect=granted +[[ "${1:-}" == --sem-permissao ]] && expect=denied +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +flutter build apk --debug >/dev/null +adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true +adb -s "$dev" install -r build/app/outputs/flutter-apk/app-debug.apk >/dev/null +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + if [[ "$expect" == granted ]]; then + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + else + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + fi +done +adb -s "$dev" emu geo fix -47.825 -15.795 >/dev/null + +# --use-application-binary: reaproveita o APK que recebeu a permissão (um +# `flutter test` normal reinstalaria e perderia a concessão). +flutter test integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary=build/app/outputs/flutter-apk/app-debug.apk \ + --dart-define=EXPECT_PERMISSION="$expect" +echo "OK — check-in por GPS real ($expect)" +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao` +Expected: os dois imprimem `OK — check-in por GPS real (granted)` e `(denied)`. + +**Caminho B** (se o Step 1 não achou `--use-application-binary`, ou se `flutter test` reinstalar e perder a concessão — sintoma: o `granted` expira por timeout): trocar o `flutter test` final por `flutter drive --driver=test_driver/integration_test.dart --target=integration_test/geofence_gps_e2e.dart -d "$dev" --use-application-binary=…` e criar `apps/acs/test_driver/integration_test.dart` com `import 'package:integration_test/integration_test_driver.dart'; Future main() => integrationDriver();`. Registrar no commit qual caminho foi preciso. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/integration_test/geofence_gps_e2e.dart scripts/qa/acs_gps_e2e.sh +git commit -m "test(acs): GPS real do check-in no emulador, com e sem permissão (RF12)" +``` + +--- + +### Task 4: Jornada completa do ACS no banco de teste (login institucional real) + +**Files:** +- Modify: `scripts/qa/otp_relay.py` (endpoint opt-in `GET /acs`) +- Modify: `scripts/qa/otp_relay_test.py` +- Create: `apps/acs/integration_test/support/e2e_acs.dart` +- Create: `apps/acs/integration_test/full_journey_e2e.dart` +- Create: `scripts/qa/acs_full_e2e.sh` + +**Interfaces:** +- Consumes: `e2e_stack.sh up|seed|down` e `.e2e/fixtures.json` (`E2eFixtures`: `acs{matricula,password}`, `patients[]{role,name,cpf,birthDate,microAreaId,id}`, `microAreaId`, `otherMicroAreaId`); `otp_relay.py` (`/now`, `/code?since=`); chaves `matricula_field`, `senha_field`, `login_button`, `login_error`, `patient_picker`, `patient_`, `patient_search`, `save_visit`, `sync_visits`, `pending_visits_count`, `ack_`; `api.Client` de `sinalacs_client`. +- Produces: `E2eAcsConfig` e `acsCredentialFromRelay()` em `support/e2e_acs.dart`; `acs_full_e2e.sh [--sem-gps]`. + +**Decisão a registrar (e que o usuário pode reverter):** a senha do ACS é aleatória e descartável, mas o `patient_full_e2e.sh` a tira de propósito do `--dart-define` (revisão I1: argv visível em `ps` e embutida no APK). Para o teste de tela ela precisa chegar ao aparelho. Escolha: **servi-la pelo relé de OTP, opt-in** (`E2E_FIXTURES_FILE` definido). Fica fora do APK; **continua legível por qualquer processo local em `127.0.0.1`** — mesma exposição do argv, só que sem ir para o APK e sem aparecer em `ps`. Aceitável para credencial sintética destruída ao final; se não for, a alternativa é só rodar o login institucional no host (como `tool/territory_check.dart`) e testar a tela com `FakeAcsBackend`. + +- [ ] **Step 1: Teste Python do endpoint `/acs` (falha primeiro)** + +Acrescentar a `scripts/qa/otp_relay_test.py`, antes do `if __name__`: + +```python +class AcsTest(unittest.TestCase): + def test_le_matricula_e_senha_do_manifesto(self): + import json, tempfile + from otp_relay import acs_do_manifesto + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump({"acs": {"matricula": "E2E-1234", "password": "s3nha"}}, f) + self.assertEqual(acs_do_manifesto(f.name), {"matricula": "E2E-1234", "senha": "s3nha"}) + + def test_sem_arquivo_nao_serve_nada(self): + from otp_relay import acs_do_manifesto + self.assertIsNone(acs_do_manifesto(None)) + self.assertIsNone(acs_do_manifesto("/nao/existe.json")) +``` + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: FAIL — `ImportError: cannot import name 'acs_do_manifesto'`. + +- [ ] **Step 2: Implementar `/acs` (opt-in)** + +Em `scripts/qa/otp_relay.py`: `import json, os` ao lado dos imports; antes de `class Handler`: + +```python +def acs_do_manifesto(caminho): + """Matrícula e senha sintéticas do ACS do manifesto de e2e, ou None. + + Só existe para o e2e de TELA do ACS. Opt-in: sem E2E_FIXTURES_FILE o relé + não serve credencial nenhuma, e o e2e do paciente segue exatamente como era.""" + if not caminho: + return None + try: + with open(caminho) as f: + acs = json.load(f)["acs"] + return {"matricula": acs["matricula"], "senha": acs["password"]} + except (OSError, KeyError, ValueError): + return None +``` + +Em `Handler.do_GET`, logo depois do bloco do `/now`: + +```python + if url.path == "/acs": + credencial = acs_do_manifesto(os.environ.get("E2E_FIXTURES_FILE")) + if credencial is None: + self.send_error(404); return + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write(json.dumps(credencial).encode()) + return +``` + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: PASS (todos os testes, os antigos inclusive). + +- [ ] **Step 3: Suporte Dart do ACS** + +Criar `apps/acs/integration_test/support/e2e_acs.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +/// Manifesto de fixtures SEM o bloco `acs` (a senha vem do relé, nunca do +/// `--dart-define`): `--dart-define=E2E_FIXTURES='{"patients":[…]}'`. +const _fixtures = String.fromEnvironment('E2E_FIXTURES'); +const relayBase = String.fromEnvironment('OTP_RELAY_BASE', defaultValue: 'http://localhost:8765'); + +class E2ePatient { + const E2ePatient({required this.id, required this.role, required this.name, required this.cpf, required this.birthDate, required this.microAreaId}); + final String id; + final String role; + final String name; + final String cpf; // nunca em log + final String birthDate; // AAAA-MM-DD + final String microAreaId; + @override + String toString() => 'E2ePatient($role)'; +} + +List e2ePatients() { + if (_fixtures.isEmpty) throw StateError('sem --dart-define=E2E_FIXTURES (use scripts/qa/acs_full_e2e.sh)'); + final map = (jsonDecode(_fixtures) as Map).cast(); + return [ + for (final raw in (map['patients']! as List).cast()) + E2ePatient( + id: raw['id'] as String, + role: raw['role'] as String, + name: raw['name'] as String, + cpf: raw['cpf'] as String, + birthDate: raw['birthDate'] as String, + microAreaId: raw['microAreaId'] as String, + ), + ]; +} + +E2ePatient e2ePatient(String role) => e2ePatients().firstWhere((p) => p.role == role); + +Future _get(String path, {String query = ''}) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: path, query: query)); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a $path'); + return body.trim(); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +/// Matrícula e senha do ACS da fixture, servidas pelo relé (opt-in). +Future<({String matricula, String senha})> acsCredentialFromRelay() async { + final json = jsonDecode(await _get('/acs')) as Map; + return (matricula: json['matricula'] as String, senha: json['senha'] as String); +} + +/// Entra como o paciente [role] pelo OTP real e devolve o token. +Future loginPatientOtp(api.Client client, String role) async { + final p = e2ePatient(role); + final pedidoEm = int.parse(await _get('/now')); + await client.auth.requestOtp(cpf: p.cpf, birthDate: DateTime.parse(p.birthDate)); + for (var i = 0; i < 20; i++) { + try { + final code = await _get('/code', query: 'since=$pedidoEm'); + final session = await client.auth.verifyOtp(cpf: p.cpf, code: code); + return session.accessToken; + } on StateError { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + throw StateError('o relé não devolveu o código do OTP'); +} +``` + +- [ ] **Step 4: Escrever a jornada (falha primeiro)** + +Criar `apps/acs/integration_test/full_journey_e2e.dart`: + +```dart +/// Jornada completa do ACS contra o BANCO DE TESTE, pela tela, com login +/// institucional real (RF07) e MQTT real. Rodada por `scripts/qa/acs_full_e2e.sh`. +/// +/// PRIVACIDADE: só fixtures sintéticas geradas na execução; nada de CPF em log. +@Timeout(Duration(minutes: 4)) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_acs.dart'; + +Future _pumpUntil(WidgetTester tester, bool Function() done, {Duration timeout = const Duration(seconds: 30)}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(done(), isTrue, reason: 'condição não atingida em ${timeout.inSeconds}s'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('login real, alerta pelo MQTT, visita pelo seletor e sincronização', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final host = const String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final patientClient = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(patientClient.close); + + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + final outsider = e2ePatient('outsider'); + + await tester.pumpWidget(SinalAcsApp(backend: backend)); + + // Review Focus 4 — senha errada: erro visível, painel fechado. + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), '${cred.senha}-errada'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_error')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir'); + + // A sessão certa logo depois funciona, sem reiniciar. + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + + // Alerta vermelho do paciente (OTP real) chega ao painel pelo broker. + final token = await loginPatientOtp(patientClient, 'api'); + final created = await patientClient.alerts.createRedAlert( + accessToken: token, + idempotencyKey: 'e2e-acs-${DateTime.now().microsecondsSinceEpoch}', + locationHash: 'sem-local-00', + ); + await _pumpUntil(tester, () => find.byKey(Key('alert_${created.alertId}')).evaluate().isNotEmpty); + await tester.tap(find.byKey(Key('ack_${created.alertId}'))); + await _pumpUntil(tester, () => find.byKey(Key('ack_${created.alertId}')).evaluate().isEmpty); + + // Visita de rotina pelo seletor: o paciente da microárea aparece; o de fora, não (RNF06). + await tester.tap(find.text('Visita')); + await _pumpUntil(tester, () => find.byKey(const Key('patient_picker')).evaluate().isNotEmpty); + expect(find.byKey(Key('patient_${main.id}')), findsOneWidget); + expect(find.byKey(Key('patient_${outsider.id}')), findsNothing, reason: 'outra microárea'); + + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('save_visit'))); + await _pumpUntil(tester, () => find.byKey(const Key('pending_visits_count')).evaluate().isNotEmpty); + await tester.tap(find.byKey(const Key('sync_visits'))); + await _pumpUntil(tester, () => find.byKey(const Key('pending_visits_count')).evaluate().isEmpty); + + // A visita está NO SERVIDOR (e só o ACS da microárea a enxerga). + final acsClient = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(acsClient.close); + final acs = await acsClient.auth.loginInstitutional(matricula: cred.matricula, password: cred.senha); + final remotas = await acsClient.visits.pull(accessToken: acs.accessToken, since: DateTime.fromMillisecondsSinceEpoch(0)); + expect(remotas.any((v) => v.patientId == main.id), isTrue); + }); +} +``` + +**Nome de arquivo (`_e2e.dart`, não `_test.dart`) é de propósito:** `flutter test integration_test` descobre `*_test.dart` recursivamente, e `e2e.sh --emulator --full` rodaria este teste contra a stack de desenvolvimento, onde não há fixtures nem relé. Com `_e2e.dart` só o script o roda, por caminho explícito. + +**Já conferido contra o código** (2026-10-01): `auth.loginInstitutional` recebe `password:` (`client.dart:138`); as abas são `Área`, `Fila`, `Mapa`, `Visita`, `Mais`; `ack_` só existe enquanto `!alert.acknowledged` (`app.dart:1123`). **Ainda a conferir ao rodar:** que a fila marca o alerta como confirmado depois do `acknowledge` (o `ack_` precisa sumir); se não sumir, ler como `app.dart:714` atualiza o alerta e trocar a espera por um texto de "confirmado" — não afrouxar a asserção. + +- [ ] **Step 5: Criar o orquestrador** + +Criar `scripts/qa/acs_full_e2e.sh` (`chmod +x`), espelhando `patient_full_e2e.sh`: + +```bash +#!/usr/bin/env bash +# +# Jornada completa do ACS no emulador-5554, contra o BANCO DE TESTE. +# +# ./scripts/qa/acs_full_e2e.sh +# +# Sobe a stack de e2e (e2e_stack.sh: banco sinalacs_e2e, sem login de +# desenvolvimento), semeia fixtures sintéticas (UUIDs, CPFs e a senha do ACS +# novos a cada execução), sobe o relé (OTP + credencial do ACS, opt-in) e roda +# integration_test/full_journey_e2e.dart no emulador. Nada é escrito no banco +# de desenvolvimento; o banco e o manifesto são apagados ao final. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin" + +dev=emulator-5554 +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +relay_pid="" +cleanup() { + [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true + rm -f .e2e/fixtures.json # primeiro: tem a senha do ACS, mesmo se o down falhar + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 \ + || echo 'aviso: e2e_stack.sh down falhou; o banco sinalacs_e2e pode ter sobrado' >&2 + echo 'A stack de desenvolvimento foi substituída pela de e2e: rode `docker compose up -d` para voltá-la.' +} +trap cleanup EXIT + +if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then + echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 + exit 1 +fi + +echo "== stack de e2e (banco de teste)" +./scripts/qa/e2e_stack.sh up +./scripts/qa/e2e_stack.sh seed +./scripts/dev/sync_dev_ca.sh >/dev/null 2>&1 || true +set -a; source .env; set +a +adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +adb -s "$dev" reverse tcp:8883 tcp:8883 >/dev/null +adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null + +E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & +relay_pid=$! +for _ in $(seq 1 20); do + curl -fs http://127.0.0.1:8765/now >/dev/null 2>&1 && break + sleep 0.25 +done +kill -0 "$relay_pid" 2>/dev/null || { echo 'erro: o relé não subiu' >&2; exit 1; } + +# Sem o bloco `acs`: a senha chega pelo relé, nunca pelo argv do flutter test nem no APK. +fixtures="$(python3 -c "import json;d=json.load(open('.e2e/fixtures.json'));d.pop('acs',None);print(json.dumps(d))")" + +echo "== jornada do ACS no emulador" +( cd apps/acs && flutter pub get >/dev/null && flutter test integration_test/full_journey_e2e.dart -d "$dev" \ + --dart-define=SINALACS_HOST=https://localhost:8443/ \ + --dart-define=SINALACS_MQTT_HOST=localhost \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=E2E_FIXTURES="$fixtures" ) +echo 'OK — jornada completa do ACS contra o banco de teste' +``` + +- [ ] **Step 6: Rodar e ver passar** + +Run: `./scripts/qa/acs_full_e2e.sh` +Expected: `OK — jornada completa do ACS contra o banco de teste`, e depois `ls .e2e/fixtures.json` → `No such file`. +Se o teste falhar por um dos três nomes do Step 4, corrigi-lo e rodar de novo; **não** afrouxar uma asserção para passar. + +- [ ] **Step 7: Voltar a stack de desenvolvimento** + +Run: `docker compose up -d && docker compose ps --format '{{.Name}} {{.Status}}'` +Expected: `sinalacs-serverpod … (healthy)`. + +- [ ] **Step 8: Commit** + +```bash +git add scripts/qa/otp_relay.py scripts/qa/otp_relay_test.py scripts/qa/acs_full_e2e.sh apps/acs/integration_test/support/e2e_acs.dart apps/acs/integration_test/full_journey_e2e.dart +git commit -m "test(acs): jornada completa com login institucional real no banco de teste" +``` + +--- + +### Task 5: Documentação + +**Files:** +- Modify: `PROGRESS.md` (seção nova "Finalização do app ACS (2026-10-01)") +- Modify: `apps/CLAUDE.md` (ACS: permissões, discador, `acs_gps_e2e.sh`, `acs_full_e2e.sh`) +- Modify: `spec/validation_report.md` (linha RF13 e L-07; **só** isso, ele é velho nos dois sentidos) +- Modify: `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4: a permissão passou a existir de fato) +- Modify: `docs/roteiro-video-sponsor.md:153` (a mensagem do SAMU mudou) + +**Interfaces:** consome tudo das Tasks 1–4. + +- [ ] **Step 1: Atualizar os textos** + +- `PROGRESS.md`: registrar os 5 achados do topo deste plano, o que mudou e o que ficou de fora (RF08 cache, UBS, MFA/refresh token), com a data. +- `apps/CLAUDE.md`: onde descreve o ACS, acrescentar uma frase por item — manifesto declara localização (guardado por `test/android_manifest_test.dart`); `EmergencyDialer` (`core/services/emergency_dialer.dart`) abre o discador, nunca liga; `scripts/qa/acs_gps_e2e.sh` e `scripts/qa/acs_full_e2e.sh` e o que cada um prova. +- `spec/validation_report.md`: linha RF13 de `ausente` para `parcial` ("SAMU abre o discador; UBS segue sem fonte de contato") e riscar L-07 (`~~…~~`) com a nota de que a UBS permanece. +- Decisão §4: nota curta "a permissão de localização em primeiro plano só passou a estar no manifesto em 2026-10-01". +- `docs/roteiro-video-sponsor.md:153`: a linha do SAMU deixa de citar a mensagem de protótipo; a da UBS fica. + +- [ ] **Step 2: Registrar o achado do paciente, sem mexer nele** + +Em `PROGRESS.md`, uma linha em "O que continua em aberto": "`apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); verificar com `aapt2 dump permissions` no APK de release antes de qualquer mudança." + +- [ ] **Step 3: Conferir links** + +Run: `./scripts/dev/check_documentation_links.sh` +Expected: sem links quebrados. + +- [ ] **Step 4: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md spec/validation_report.md docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md docs/roteiro-video-sponsor.md +git commit -m "docs: registra a finalização do app ACS (localização, SAMU, e2e)" +``` + +--- + +### Task 6: Barra final no emulador + +**Files:** nenhum (só verificação; qualquer correção volta à tarefa dona). + +**Interfaces:** consome Tasks 1–5. + +- [ ] **Step 1: Suítes herméticas** + +Run: `cd apps/acs && flutter analyze && flutter test` +Expected: `No issues found!` e **mais** de 198 testes (198 + 3 manifesto + 1 manifesto `tel` + 5 do discador = **207**), `All tests passed!`. + +- [ ] **Step 2: Emulador e stack no ar** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$HOME/flutter/bin" +adb devices | grep -q "emulator-5554.*device" || { (cd /tmp && nohup emulator -avd Medium_Phone -port 5554 -no-snapshot-save -no-boot-anim >/tmp/emu.log 2>&1 &); adb wait-for-device; until [ "$(adb -s emulator-5554 shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done; } +docker compose ps --format '{{.Name}} {{.Status}}' | grep -q 'sinalacs-serverpod.*healthy' || docker compose up --build -d +docker compose up database-seed +./scripts/dev/sync_dev_ca.sh +``` +Expected: `adb devices` lista `emulator-5554 device`; `sinalacs-serverpod … (healthy)`. + +- [ ] **Step 3: Bateria `integration_test` do ACS (stack de desenvolvimento)** + +```bash +set -a; source .env; set +a +adb -s emulator-5554 reverse tcp:8443 tcp:443; adb -s emulator-5554 reverse tcp:8883 tcp:8883 +( cd apps/acs && flutter test integration_test -d emulator-5554 \ + --dart-define=SINALACS_HOST=https://localhost:8443/ \ + --dart-define=SINALACS_MQTT_HOST=localhost \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=GOOGLE_MAPS_API_KEY="$GOOGLE_MAPS_API_KEY" ) +``` +Expected: os 15 do baseline **+ 1** (o do discador, Task 2) = 16 passam. Os dois `*_e2e.dart` não rodam aqui (o sufixo os esconde da descoberta por pasta), o que mantém `e2e.sh --emulator --full` e a CI iguais. + +- [ ] **Step 4: Os dois e2e novos** + +Run: `./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao && ./scripts/qa/acs_full_e2e.sh` +Expected: três `OK — …`. Depois: `docker compose up -d` e `ls .e2e/` sem `fixtures.json`. + +- [ ] **Step 5: Guardas do repositório** + +Run: `./scripts/qa/ci_invariants.sh && python3 scripts/qa/otp_relay_test.py && ./scripts/dev/check_documentation_links.sh` +Expected: todos verdes. (Os scripts novos **não** entram na CI: o `ci_invariants.sh` vigia a lista de jobs e o `android-e2e` já roda `e2e.sh --emulator`. Incluir o `acs_full_e2e.sh` na CI é decisão à parte — exige o banco de teste no runner.) + +- [ ] **Step 6: Estado do repositório** + +Run: `git status --short && git log --oneline -6` +Expected: árvore limpa; um commit por tarefa, **sem** `Co-Authored-By`. Sem push. + +--- + +## Self-review + +**Cobertura:** RF12 (GPS real: Tasks 1, 3), RF13/L-07 (SAMU: Task 2; UBS fora, justificado), RF07 + RF09 + RF11 + RBAC fim a fim no emulador (Task 4: login real, alerta MQTT, ACK, seletor com território, visita sincronizada e conferida no servidor), documentação (Task 5), execução no `emulator-5554` (Tasks 2–4, 6). RF08 cache, MFA/refresh token, iOS ficam explicitamente fora, com motivo. + +**Placeholders:** dois pontos dependem de execução e têm o caminho de decisão escrito: o `ack_` sumir após confirmar (Task 4, Step 4) e o caminho B do `--use-application-binary` (Task 3, Step 4, com os comandos). Nenhum "TBD". + +**Consistência de tipos/nomes:** `EmergencyDialer.dial(String) → Future` e `samuNumber` (Task 2) usados só na Task 2; `declares()`/`manifest` (Task 1) reusados na Task 2; `acsCredentialFromRelay()` devolve `({String matricula, String senha})` e é consumido com `.matricula`/`.senha` na Task 4; `E2ePatient.id` usado em `patient_`. + +**Review Focus:** as 5 linhas têm teste dono — 1 → Task 3 (`denied`), 2 e 3 → Task 2, 4 e 5 → Task 4. + +**Riscos que só a execução resolve:** (1) `flutter test --use-application-binary` preservar a permissão concedida (Task 3 tem o caminho B); (2) a imagem `Medium_Phone` ter discador (Task 2, Step 6 diz como distinguir); (3) o `pumpAndSettle` nunca assenta com MQTT/timers ativos — por isso `_pumpUntil` em vez de `pumpAndSettle`. From 25319aa06858c7c50ecf23c11222953cd7bfaf96 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 19:27:05 -0400 Subject: [PATCH 03/43] =?UTF-8?q?fix(acs):=20declara=20localiza=C3=A7?= =?UTF-8?q?=C3=A3o=20e=20INTERNET=20no=20manifesto=20de=20release=20(RF12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/app/src/main/AndroidManifest.xml | 7 +++++ apps/acs/test/android_manifest_test.dart | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 apps/acs/test/android_manifest_test.dart diff --git a/apps/acs/android/app/src/main/AndroidManifest.xml b/apps/acs/android/app/src/main/AndroidManifest.xml index 84c2c3ab..0d59e354 100644 --- a/apps/acs/android/app/src/main/AndroidManifest.xml +++ b/apps/acs/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,11 @@ + + + + RegExp( + '', + ).hasMatch(manifest); + + test('declara localização precisa e aproximada (RF12)', () { + expect(declares('ACCESS_FINE_LOCATION'), isTrue); + expect(declares('ACCESS_COARSE_LOCATION'), isTrue); + }); + + test('declara INTERNET no manifesto de release, sem depender de plugin', () { + expect(declares('INTERNET'), isTrue); + }); + + test('NÃO pede localização em segundo plano (decisão §4 de 2026-09-16)', () { + expect(declares('ACCESS_BACKGROUND_LOCATION'), isFalse, + reason: 'o geofence é só em primeiro plano; background exigiria revisão da loja e LGPD'); + }); +} From 01722a34e0dc0b320dc5ab09dbc39a2a27001216 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 19:29:41 -0400 Subject: [PATCH 04/43] =?UTF-8?q?feat(acs):=20bot=C3=A3o=20do=20SAMU=20abr?= =?UTF-8?q?e=20o=20discador=20com=20192=20(RF13,=20L-07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/app/src/main/AndroidManifest.xml | 4 + apps/acs/integration_test/map_flow_test.dart | 9 ++ apps/acs/lib/app/app.dart | 42 ++++++- .../lib/core/services/emergency_dialer.dart | 28 +++++ apps/acs/pubspec.lock | 64 +++++++++++ apps/acs/pubspec.yaml | 3 + apps/acs/test/android_manifest_test.dart | 9 ++ apps/acs/test/escalation_dialer_test.dart | 108 ++++++++++++++++++ 8 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 apps/acs/lib/core/services/emergency_dialer.dart create mode 100644 apps/acs/test/escalation_dialer_test.dart diff --git a/apps/acs/android/app/src/main/AndroidManifest.xml b/apps/acs/android/app/src/main/AndroidManifest.xml index 0d59e354..29d3419a 100644 --- a/apps/acs/android/app/src/main/AndroidManifest.xml +++ b/apps/acs/android/app/src/main/AndroidManifest.xml @@ -47,6 +47,10 @@ In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> + + + + diff --git a/apps/acs/integration_test/map_flow_test.dart b/apps/acs/integration_test/map_flow_test.dart index f3736dd8..7a35937d 100644 --- a/apps/acs/integration_test/map_flow_test.dart +++ b/apps/acs/integration_test/map_flow_test.dart @@ -1,3 +1,4 @@ +import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -118,4 +119,12 @@ void main() { await tester.tap(find.byKey(const Key('escalation_visit'))); expect(selected?.alertId, alert.alertId); }); + + testWidgets('o discador do aparelho resolve tel:192 (RF13)', (tester) async { + // Prova o que o widget test não alcança: declarada e um + // handler de `tel:` presente no aparelho. Não abre a chamada. + final available = await canLaunchUrl(Uri(scheme: 'tel', path: '192')); + expect(available, isTrue, + reason: 'sem para tel: (Android 11+) ou sem discador no emulador'); + }); } diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 957d81f1..9bf0714d 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -13,6 +13,7 @@ import 'package:sinalacs_acs/core/network/backend_scope.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/alert_feed.dart'; import 'package:sinalacs_acs/core/services/alert_queue.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; import 'package:sinalacs_acs/core/services/reconnect_schedule.dart'; @@ -1912,15 +1913,46 @@ class _VisitRegistrationScreenState extends State { } } -class EscalationScreen extends StatelessWidget { - const EscalationScreen({super.key, this.alert, this.onVisit}); +class EscalationScreen extends StatefulWidget { + const EscalationScreen({ + super.key, + this.alert, + this.onVisit, + this.dialer = const UrlLauncherEmergencyDialer(), + }); final PrioritizedAlert? alert; final void Function(PrioritizedAlert alert)? onVisit; + final EmergencyDialer dialer; + + @override + State createState() => _EscalationScreenState(); +} + +class _EscalationScreenState extends State { + /// Trava o toque duplo: dois `launchUrl` seguidos empilham dois discadores. + bool _dialing = false; + + Future _callSamu() async { + if (_dialing) return; + setState(() => _dialing = true); + var opened = false; + try { + opened = await widget.dialer.dial(samuNumber); + } catch (_) { + opened = false; + } + if (!mounted) return; + setState(() => _dialing = false); + if (!opened) { + _message(context, 'Não foi possível abrir o discador. Ligue manualmente para $samuNumber.'); + } + } @override Widget build(BuildContext context) { - final current = alert; + final current = widget.alert; + final onVisit = widget.onVisit; return _page([ const Text('Escalonamento rápido', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), @@ -1930,7 +1962,7 @@ class EscalationScreen extends StatelessWidget { _InfoRow('Local (hash)', current?.locationHash ?? '—'), const SizedBox(height: 20), FilledButton.icon( - onPressed: () => _message(context, 'Discagem não está integrada neste protótipo.'), + onPressed: _dialing ? null : _callSamu, // Alvo de toque de 60x60 (padrão de emergência do PRD): o default do // Material 3 para `FilledButton.icon` fica em 40dp de altura visual, // abaixo do exigido para uma ação de acionar o SAMU. @@ -1948,7 +1980,7 @@ class EscalationScreen extends StatelessWidget { const SizedBox(height: 12), OutlinedButton.icon( key: const Key('escalation_visit'), - onPressed: onVisit == null ? null : () => onVisit!(current), + onPressed: onVisit == null ? null : () => onVisit(current), style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), icon: const Icon(Icons.alt_route_outlined), label: const Text('Iniciar rota de visita'), diff --git a/apps/acs/lib/core/services/emergency_dialer.dart b/apps/acs/lib/core/services/emergency_dialer.dart new file mode 100644 index 00000000..a13bd6a7 --- /dev/null +++ b/apps/acs/lib/core/services/emergency_dialer.dart @@ -0,0 +1,28 @@ +import 'package:url_launcher/url_launcher.dart'; + +/// Número do SAMU. Constante de domínio (PRD, RF13), não configuração. +const samuNumber = '192'; + +/// Abre o discador do aparelho com o número já digitado. +/// +/// **Nunca liga sozinho:** `tel:` abre o discador e a pessoa confirma a +/// chamada. É o que dispensa `CALL_PHONE` no manifesto (permissão sensível, +/// revisada pela loja) e evita uma ligação por toque acidental. +abstract interface class EmergencyDialer { + /// `true` se o discador foi aberto; `false` se o aparelho não tem como. + Future dial(String number); +} + +class UrlLauncherEmergencyDialer implements EmergencyDialer { + const UrlLauncherEmergencyDialer(); + + @override + Future dial(String number) async { + try { + return await launchUrl(Uri(scheme: 'tel', path: number)); + } catch (_) { + // Qualquer falha vira "não abriu": quem chama mostra o número em texto. + return false; + } + } +} diff --git a/apps/acs/pubspec.lock b/apps/acs/pubspec.lock index ecca7cfd..e8a2e50e 100644 --- a/apps/acs/pubspec.lock +++ b/apps/acs/pubspec.lock @@ -835,6 +835,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" uuid: dependency: transitive description: diff --git a/apps/acs/pubspec.yaml b/apps/acs/pubspec.yaml index d37799c5..d9be1ebd 100644 --- a/apps/acs/pubspec.yaml +++ b/apps/acs/pubspec.yaml @@ -31,6 +31,9 @@ dependencies: geolocator: ^12.0.0 google_maps_flutter: ^2.10.1 qr_flutter: ^4.1.0 + # RF13: abre o discador do aparelho (tel:192). O PRD já a nomeia na matriz + # de capacidades; é invólucro de intent do sistema, não framework novo. + url_launcher: ^6.3.0 # Preferência de tema (claro/escuro/automático) — dado de UI não sensível, # não precisa do Keystore/Keychain do flutter_secure_storage. shared_preferences: ^2.3.2 diff --git a/apps/acs/test/android_manifest_test.dart b/apps/acs/test/android_manifest_test.dart index 263204a4..b8d7e4cf 100644 --- a/apps/acs/test/android_manifest_test.dart +++ b/apps/acs/test/android_manifest_test.dart @@ -24,6 +24,15 @@ void main() { expect(declares('INTERNET'), isTrue); }); + test('declara a consulta ao discador (tel:) para o botão do SAMU (RF13)', () { + // Android 11+: sem , `canLaunchUrl(tel:)` devolve false mesmo com discador. + expect( + RegExp(r'\s*') + .hasMatch(manifest), + isTrue, + ); + }); + test('NÃO pede localização em segundo plano (decisão §4 de 2026-09-16)', () { expect(declares('ACCESS_BACKGROUND_LOCATION'), isFalse, reason: 'o geofence é só em primeiro plano; background exigiria revisão da loja e LGPD'); diff --git a/apps/acs/test/escalation_dialer_test.dart b/apps/acs/test/escalation_dialer_test.dart new file mode 100644 index 00000000..bfd7b31b --- /dev/null +++ b/apps/acs/test/escalation_dialer_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; + +import 'support/fakes.dart'; + +class _FakeDialer implements EmergencyDialer { + _FakeDialer({this.opens = true, this.throws = false}); + + final bool opens; + final bool throws; + final dialed = []; + + @override + Future dial(String number) async { + dialed.add(number); + if (throws) throw StateError('sem discador'); + return opens; + } +} + +class _GatedDialer implements EmergencyDialer { + _GatedDialer(this._gate, this.dialed); + + final Completer _gate; + final List dialed; + + @override + Future dial(String number) { + dialed.add(number); + return _gate.future; + } +} + +Future _pump(WidgetTester tester, _FakeDialer dialer) => tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + )); + +void main() { + testWidgets('o botão do SAMU abre o discador com 192', (tester) async { + final dialer = _FakeDialer(); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.dialed, ['192']); + expect(find.text('Discagem não está integrada neste protótipo.'), findsNothing); + }); + + testWidgets('sem discador, mostra o número em texto em vez de falhar em silêncio', (tester) async { + final dialer = _FakeDialer(opens: false); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('um erro do discador cai no mesmo aviso, sem derrubar a tela', (tester) async { + final dialer = _FakeDialer(throws: true); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('toque duplo não abre dois discadores', (tester) async { + // O discador real leva tempo; o segundo toque chega com o primeiro em voo. + final aberto = Completer(); + final dialed = []; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EscalationScreen( + alert: testAlert(alertId: 'a1'), + dialer: _GatedDialer(aberto, dialed), + ), + ), + )); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + await tester.tap(find.text('Ligar para o SAMU (192)'), warnIfMissed: false); + await tester.pump(); + aberto.complete(true); + await tester.pump(); + + expect(dialed, ['192']); + }); + + testWidgets('o botão da UBS continua sendo o aviso honesto (sem contato cadastrado)', (tester) async { + await _pump(tester, _FakeDialer()); + + await tester.tap(find.text('Encaminhar para UBS Central')); + await tester.pump(); + + expect(find.text('Encaminhamento será integrado à UBS.'), findsOneWidget); + }); +} From f28953759ef3b5610cd7f9492cef1fba63fa0dbf Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 19:58:10 -0400 Subject: [PATCH 05/43] =?UTF-8?q?test(acs):=20permiss=C3=A3o=20de=20locali?= =?UTF-8?q?za=C3=A7=C3=A3o=20no=20emulador,=20concedida=20e=20negada=20(RF?= =?UTF-8?q?12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration_test/geofence_gps_e2e.dart | 75 +++++++++++++++++++ apps/acs/test_driver/integration_test.dart | 6 ++ scripts/qa/acs_gps_e2e.sh | 50 +++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 apps/acs/integration_test/geofence_gps_e2e.dart create mode 100644 apps/acs/test_driver/integration_test.dart create mode 100755 scripts/qa/acs_gps_e2e.sh diff --git a/apps/acs/integration_test/geofence_gps_e2e.dart b/apps/acs/integration_test/geofence_gps_e2e.dart new file mode 100644 index 00000000..c4d46eb3 --- /dev/null +++ b/apps/acs/integration_test/geofence_gps_e2e.dart @@ -0,0 +1,75 @@ +/// RF12 no aparelho: a PERMISSÃO de localização em runtime e o que a tela faz +/// com ela. Cenário e pré-requisitos em `scripts/qa/acs_gps_e2e.sh` (concede ou +/// revoga a permissão por `pm` antes de rodar). `--dart-define=EXPECT_PERMISSION=granted|denied`. +/// +/// O que isto prova: `pm grant` só funciona se o manifesto DECLARA a permissão +/// (o defeito que o `android_manifest_test.dart` guarda), e o app, com ela, +/// enxerga `whileInUse`; sem ela, a tela diz "indisponível" e não trava. +/// O que NÃO prova: a chegada de um fix de GPS. No AVD usado (Android 16, Play +/// Services) nem `adb emu geo fix`, nem provider de teste, nem +/// `forceLocationManager` entregam posição ao app; "Local alcançado" com posição +/// segue coberto por `map_flow_test.dart` e pelos widget tests (posição injetada). +/// +/// Sufixo `_e2e.dart` (não `_test.dart`) de propósito: `flutter test +/// integration_test` descobre `*_test.dart` por pasta, e este arquivo só faz +/// sentido pelo script. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import '../test/support/fakes.dart'; + +const _expect = String.fromEnvironment('EXPECT_PERMISSION', defaultValue: 'granted'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('a permissão de localização no aparelho ($_expect)', (tester) async { + final alert = testAlert(alertId: 'gps-1', locationCell: testLocationCell); + + final permission = await Geolocator.checkPermission(); + if (_expect == 'granted') { + expect(permission, anyOf(LocationPermission.whileInUse, LocationPermission.always), + reason: 'o manifesto precisa declarar ACCESS_FINE_LOCATION para a permissão existir'); + } else { + expect(permission, LocationPermission.denied); + } + + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) { + queue.upsert(alert); // `initialAlert` só seleciona; a tela lê a fila + return FakeAlertFeed(queue); + }, + initialAlert: alert, + // `currentPosition` AUSENTE de propósito: a posição tem de vir do Geolocator. + )); + await tester.enterText(find.byKey(const Key('matricula_field')), '123456'); + await tester.enterText(find.byKey(const Key('senha_field')), 'qualquer'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpFor(tester, const Duration(seconds: 3)); + + await tester.tap(find.text('Mais')); + await _pumpFor(tester, const Duration(milliseconds: 500)); + await tester.tap(find.text('Geofencing')); + await _pumpFor(tester, const Duration(seconds: 3)); + + if (_expect == 'denied') { + // Review Focus 1: permissão negada nunca trava o ACS. + expect(find.textContaining('Localização atual indisponível'), findsOneWidget); + expect(find.byKey(const Key('geofence_visit')), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); +} + +Future _pumpFor(WidgetTester tester, Duration d) async { + final fim = DateTime.now().add(d); + while (DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 100)); + } +} diff --git a/apps/acs/test_driver/integration_test.dart b/apps/acs/test_driver/integration_test.dart new file mode 100644 index 00000000..e10308b8 --- /dev/null +++ b/apps/acs/test_driver/integration_test.dart @@ -0,0 +1,6 @@ +import 'package:integration_test/integration_test_driver.dart'; + +/// Driver do `flutter drive`, usado por `scripts/qa/acs_gps_e2e.sh` (só ele +/// aceita `--use-application-binary`, preciso para manter a permissão de +/// localização concedida por `pm grant`). +Future main() => integrationDriver(); diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh new file mode 100755 index 00000000..15123393 --- /dev/null +++ b/scripts/qa/acs_gps_e2e.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Permissão de localização do ACS (RF12) no emulador-5554. +# +# ./scripts/qa/acs_gps_e2e.sh # concedida: o app a enxerga (whileInUse) +# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # negada: "indisponível", sem travar +# +# O diálogo de permissão é do sistema e o integration_test não o toca: o APK é +# instalado já com a permissão concedida (ou revogada) por `pm` — e `pm grant` +# falha se o manifesto não a declara, que é o defeito que este script guarda. +# NÃO prova a chegada de um fix de GPS: neste AVD nada entrega posição ao app +# (ver o cabeçalho de integration_test/geofence_gps_e2e.dart). +# Roda por `flutter drive`: só ele aceita --use-application-binary (um +# `flutter test` reinstalaria o APK e perderia a concessão), e o APK é +# construído com o próprio teste como alvo. +# Não precisa da stack: o teste usa FakeAcsBackend. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +[[ -f .env ]] || { echo 'erro: rode ./scripts/dev/bootstrap_env.sh'; exit 1; } +set -a; source .env; set +a # MQTT_ACS_PASSWORD: o build do APK a exige +cd apps/acs + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +apk=build/app/outputs/flutter-apk/app-debug.apk +expect=granted +[[ "${1:-}" == --sem-permissao ]] && expect=denied +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +flutter build apk --debug --target=integration_test/geofence_gps_e2e.dart \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=EXPECT_PERMISSION="$expect" >/dev/null +adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true +adb -s "$dev" install -r "$apk" >/dev/null +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + if [[ "$expect" == granted ]]; then + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + else + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + fi +done + +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" \ + --dart-define=EXPECT_PERMISSION="$expect" +echo "OK — check-in por GPS real ($expect)" From 89617057d58362e4d636070adfd256773c20b558 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:07:55 -0400 Subject: [PATCH 06/43] test(acs): jornada completa com login institucional real no banco de teste; corrige setState no dispose do painel --- .../integration_test/full_journey_e2e.dart | 99 +++++++++++++++++++ .../acs/integration_test/support/e2e_acs.dart | 67 +++++++++++++ apps/acs/lib/app/app.dart | 4 + apps/acs/test/shell_dispose_test.dart | 36 +++++++ scripts/qa/acs_full_e2e.sh | 62 ++++++++++++ scripts/qa/otp_relay.py | 23 ++++- scripts/qa/otp_relay_test.py | 13 +++ 7 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 apps/acs/integration_test/full_journey_e2e.dart create mode 100644 apps/acs/integration_test/support/e2e_acs.dart create mode 100644 apps/acs/test/shell_dispose_test.dart create mode 100755 scripts/qa/acs_full_e2e.sh diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart new file mode 100644 index 00000000..d6e27f7a --- /dev/null +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -0,0 +1,99 @@ +/// Jornada completa do ACS contra o BANCO DE TESTE, pela tela, com login +/// institucional real (RF07): senha errada, seletor de pacientes restrito à +/// microárea (RNF06) e visita sincronizada, conferida no servidor. Rodada por +/// `scripts/qa/acs_full_e2e.sh`. +/// +/// NÃO cobre MQTT/ACK: o `aclfile` do broker só libera o UUID de microárea do +/// seed de desenvolvimento e as fixtures daqui são aleatórias, então o broker +/// nega o ACS. O alerta pelo broker é provado por `smoke_test.dart` e +/// `red_alert_cycle_test.dart` na stack de desenvolvimento. +/// +/// Sufixo `_e2e.dart` (não `_test.dart`): `flutter test integration_test` +/// descobre `*_test.dart` por pasta e o `e2e.sh --full` rodaria isto contra a +/// stack de desenvolvimento, onde não há fixtures nem relé. +/// +/// PRIVACIDADE: só fixtures sintéticas geradas na execução; nada de CPF em log. +@Timeout(Duration(minutes: 4)) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_acs.dart'; + +Future _pumpUntil(WidgetTester tester, bool Function() done, + {Duration timeout = const Duration(seconds: 30)}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(done(), isTrue, reason: 'condição não atingida em ${timeout.inSeconds}s'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('login real, seletor por microárea, visita e sincronização', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + api.Client novoCliente() => api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + final outsider = e2ePatient('outsider'); + + await tester.pumpWidget(SinalAcsApp(backend: backend)); + + // Review Focus 4 — senha errada: erro visível, painel fechado. + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), '${cred.senha}-errada'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_error')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir'); + + // A sessão certa logo depois funciona, sem reiniciar. + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + + // Visita de rotina pelo seletor: o paciente da microárea aparece; o de fora, não (RNF06). + await tester.tap(find.text('Visita')); + await _pumpUntil(tester, () => find.byKey(const Key('patient_picker')).evaluate().isNotEmpty); + expect(find.byKey(Key('patient_${main.id}')), findsOneWidget); + expect(find.byKey(Key('patient_${outsider.id}')), findsNothing, reason: 'outra microárea'); + + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('save_visit'))); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: 1').evaluate().isNotEmpty); + await tester.tap(find.byKey(const Key('sync_visits'))); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: 0').evaluate().isNotEmpty); + + // A visita está NO SERVIDOR (conferida por um cliente separado, com login real). + final acsClient = novoCliente(); + addTearDown(acsClient.close); + final acs = await acsClient.auth.loginInstitutional(matricula: cred.matricula, password: cred.senha); + final remotas = await acsClient.visits.pull( + accessToken: acs.accessToken, + since: DateTime.fromMillisecondsSinceEpoch(0), + ); + expect(remotas.any((v) => v.patientId == main.id), isTrue); + + // Desmonta o app aqui: o feed e os timers do painel ainda estão ativos e o + // desmonte automático do fim do teste os pegava em meio ao descarte. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/apps/acs/integration_test/support/e2e_acs.dart b/apps/acs/integration_test/support/e2e_acs.dart new file mode 100644 index 00000000..769aa4c8 --- /dev/null +++ b/apps/acs/integration_test/support/e2e_acs.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Manifesto de fixtures SEM o bloco `acs` (a senha vem do relé, nunca do +/// `--dart-define`): `--dart-define=E2E_FIXTURES='{"patients":[…]}'`. +const _fixtures = String.fromEnvironment('E2E_FIXTURES'); +const relayBase = String.fromEnvironment('OTP_RELAY_BASE', defaultValue: 'http://localhost:8765'); + +class E2ePatient { + const E2ePatient({ + required this.id, + required this.role, + required this.name, + required this.cpf, + required this.birthDate, + required this.microAreaId, + }); + final String id; + final String role; + final String name; + final String cpf; // nunca em log + final String birthDate; // AAAA-MM-DD + final String microAreaId; + @override + String toString() => 'E2ePatient($role)'; +} + +List e2ePatients() { + if (_fixtures.isEmpty) { + throw StateError('sem --dart-define=E2E_FIXTURES (use scripts/qa/acs_full_e2e.sh)'); + } + final map = (jsonDecode(_fixtures) as Map).cast(); + return [ + for (final raw in (map['patients']! as List).cast()) + E2ePatient( + id: raw['id'] as String, + role: raw['role'] as String, + name: raw['name'] as String, + cpf: raw['cpf'] as String, + birthDate: raw['birthDate'] as String, + microAreaId: raw['microAreaId'] as String, + ), + ]; +} + +E2ePatient e2ePatient(String role) => e2ePatients().firstWhere((p) => p.role == role); + +Future _get(String path, {String query = ''}) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: path, query: query)); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a $path'); + return body.trim(); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +/// Matrícula e senha do ACS da fixture, servidas pelo relé (opt-in). +Future<({String matricula, String senha})> acsCredentialFromRelay() async { + final json = jsonDecode(await _get('/acs')) as Map; + return (matricula: json['matricula'] as String, senha: json['senha'] as String); +} diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 9bf0714d..c0c0842f 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -726,6 +726,10 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver _reconnectTimer?.cancel(); _periodicSyncTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); + // Solta o callback ANTES de parar: `stop()` desconecta o MQTT, que avisa + // `onConnectionChanged(false)`, e `mounted` ainda é `true` durante o + // `dispose` — o `setState` caía num elemento já defunct (assertion). + _feed.onConnectionChanged = null; _feed.stop(); _queue.dispose(); super.dispose(); diff --git a/apps/acs/test/shell_dispose_test.dart b/apps/acs/test/shell_dispose_test.dart new file mode 100644 index 00000000..26d7fe8f --- /dev/null +++ b/apps/acs/test/shell_dispose_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'support/fakes.dart'; + +/// Feed que avisa a desconexão ao parar, como o `MqttAlertFeed` real faz: +/// `stop()` → `MqttClient.disconnect` → `onConnectionChanged(false)`. +class _FeedQueAvisaAoParar extends FakeAlertFeed { + _FeedQueAvisaAoParar(super.queue); + + @override + void stop() { + super.stop(); + onConnectionChanged?.call(false); + } +} + +void main() { + testWidgets('desmontar o painel não chama setState nele mesmo ao parar o feed', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) => _FeedQueAvisaAoParar(queue), + )); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.textContaining('ACS •'), findsOneWidget, reason: 'o painel precisa estar aberto'); + + await tester.pumpWidget(const SizedBox.shrink()); + + expect(tester.takeException(), isNull); + }); +} diff --git a/scripts/qa/acs_full_e2e.sh b/scripts/qa/acs_full_e2e.sh new file mode 100755 index 00000000..84d98470 --- /dev/null +++ b/scripts/qa/acs_full_e2e.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Jornada completa do ACS no emulador-5554, contra o BANCO DE TESTE. +# +# ./scripts/qa/acs_full_e2e.sh +# +# Sobe a stack de e2e (e2e_stack.sh: banco sinalacs_e2e, sem login de +# desenvolvimento), semeia fixtures sintéticas (UUIDs, CPFs e a senha do ACS +# novos a cada execução), sobe o relé (OTP + credencial do ACS, opt-in) e roda +# integration_test/full_journey_e2e.dart no emulador. Nada é escrito no banco +# de desenvolvimento; o banco e o manifesto são apagados ao final. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin" + +dev=emulator-5554 +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +relay_pid="" +cleanup() { + [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true + rm -f .e2e/fixtures.json # primeiro: tem a senha do ACS, mesmo se o down falhar + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 \ + || echo 'aviso: e2e_stack.sh down falhou; o banco sinalacs_e2e pode ter sobrado' >&2 + echo 'A stack de desenvolvimento foi substituída pela de e2e: rode `docker compose up -d` para voltá-la.' +} +trap cleanup EXIT + +if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then + echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 + exit 1 +fi + +echo "== stack de e2e (banco de teste)" +./scripts/qa/e2e_stack.sh up +./scripts/qa/e2e_stack.sh seed +./scripts/dev/sync_dev_ca.sh >/dev/null 2>&1 || true +set -a; source .env; set +a +adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +adb -s "$dev" reverse tcp:8883 tcp:8883 >/dev/null +adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null + +E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & +relay_pid=$! +for _ in $(seq 1 20); do + curl -fs http://127.0.0.1:8765/now >/dev/null 2>&1 && break + sleep 0.25 +done +kill -0 "$relay_pid" 2>/dev/null || { echo 'erro: o relé não subiu' >&2; exit 1; } + +# Sem o bloco `acs`: a senha chega pelo relé, nunca pelo argv do flutter test nem no APK. +fixtures="$(python3 -c "import json;d=json.load(open('.e2e/fixtures.json'));d.pop('acs',None);print(json.dumps(d))")" + +echo "== jornada do ACS no emulador" +( cd apps/acs && flutter pub get >/dev/null && flutter test integration_test/full_journey_e2e.dart -d "$dev" \ + --dart-define=SINALACS_HOST=https://localhost:8443/ \ + --dart-define=SINALACS_MQTT_HOST=localhost \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=E2E_FIXTURES="$fixtures" ) +echo 'OK — jornada completa do ACS contra o banco de teste' diff --git a/scripts/qa/otp_relay.py b/scripts/qa/otp_relay.py index 405b00a4..8f26e60c 100755 --- a/scripts/qa/otp_relay.py +++ b/scripts/qa/otp_relay.py @@ -5,7 +5,7 @@ `docker logs`. Este servidor expõe só o código mais recente, sem destino nem CPF (o log não os tem). Escuta em 127.0.0.1; o emulador chega por `adb reverse`. """ -import re, subprocess, sys, time +import json, os, re, subprocess, sys, time from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs @@ -27,6 +27,20 @@ def host_permitido(host, porta): def count_codes(log_text): return len(PADRAO.findall(log_text)) +def acs_do_manifesto(caminho): + """Matrícula e senha sintéticas do ACS do manifesto de e2e, ou None. + + Só existe para o e2e de TELA do ACS. Opt-in: sem E2E_FIXTURES_FILE o relé + não serve credencial nenhuma, e o e2e do paciente segue exatamente como era.""" + if not caminho: + return None + try: + with open(caminho) as f: + acs = json.load(f)["acs"] + return {"matricula": acs["matricula"], "senha": acs["password"]} + except (OSError, KeyError, ValueError): + return None + class Handler(BaseHTTPRequestHandler): porta = 8765 container = "sinalacs-serverpod" @@ -40,6 +54,13 @@ def do_GET(self): self.send_response(200); self.send_header("Content-Type", "text/plain"); self.end_headers() self.wfile.write(str(int(time.time() * 1000)).encode()) return + if url.path == "/acs": + credencial = acs_do_manifesto(os.environ.get("E2E_FIXTURES_FILE")) + if credencial is None: + self.send_error(404); return + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write(json.dumps(credencial).encode()) + return if url.path not in ("/code", "/count"): self.send_error(404); return since_ms = int(parse_qs(url.query).get("since", ["0"])[0]) diff --git a/scripts/qa/otp_relay_test.py b/scripts/qa/otp_relay_test.py index 5608c8f1..1abb3604 100644 --- a/scripts/qa/otp_relay_test.py +++ b/scripts/qa/otp_relay_test.py @@ -30,5 +30,18 @@ def test_recusa_outro_host_contra_dns_rebinding(self): for h in (None, "", "evil.example:8765", "localhost:9999", "127.0.0.1", "localhost.evil.example:8765"): self.assertFalse(host_permitido(h, 8765), h) +class AcsTest(unittest.TestCase): + def test_le_matricula_e_senha_do_manifesto(self): + import json, tempfile + from otp_relay import acs_do_manifesto + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump({"acs": {"matricula": "E2E-1234", "password": "s3nha"}}, f) + self.assertEqual(acs_do_manifesto(f.name), {"matricula": "E2E-1234", "senha": "s3nha"}) + + def test_sem_arquivo_nao_serve_nada(self): + from otp_relay import acs_do_manifesto + self.assertIsNone(acs_do_manifesto(None)) + self.assertIsNone(acs_do_manifesto("/nao/existe.json")) + if __name__ == "__main__": unittest.main() From 1fa218e0c59e01cd278b45663c77324c613a6987 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:08:34 -0400 Subject: [PATCH 07/43] =?UTF-8?q?docs:=20registra=20a=20finaliza=C3=A7?= =?UTF-8?q?=C3=A3o=20do=20app=20ACS=20(localiza=C3=A7=C3=A3o,=20SAMU,=20e2?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 11 +++++++++++ apps/CLAUDE.md | 2 ++ docs/roteiro-video-sponsor.md | 2 +- .../2026-09-16-decisoes-produto-pos-validacao.md | 7 +++++++ spec/validation_report.md | 4 ++-- 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index dfaa7ff0..889bbd1e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -558,6 +558,17 @@ Atenção: o código atual já valida a operação crítica de alerta vermelho e --- +## Finalização do app ACS (2026-10-01) + +Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `emulator-5554`. + +- **RF12 — permissão de localização.** O manifesto do ACS não declarava `ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION` (nem `INTERNET`, que só vinha de plugin): sem elas o GPS do check-in nunca chegava, e os testes passavam porque injetam a posição. Declaradas e guardadas por `test/android_manifest_test.dart`. `./scripts/qa/acs_gps_e2e.sh [--sem-permissao]` prova a permissão em runtime e falha com o manifesto antigo. **Não prova a chegada de um fix de GPS**: neste AVD (Android 16, Play Services) nem `adb emu geo fix`, nem provider de teste, nem `forceLocationManager` entregam posição ao app; GPS real em aparelho físico segue sem prova em dispositivo. +- **RF13 — SAMU.** O botão abre o discador com 192 (`core/services/emergency_dialer.dart`, `url_launcher`, `` para `tel:`), sem ligar sozinho e sem `CALL_PHONE`; sem discador mostra o número em texto; trava de toque duplo. "Encaminhar para UBS Central" segue sendo um aviso (sem fonte de contato da UBS: decisão de produto). +- **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. +- **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). +- **Fora:** RF08 (cache persistido da microárea offline — exige desenho de LGPD antes), MFA/refresh token, iOS (`apps/acs/ios/` não existe), botão da UBS. +- **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. + ## Migração para Serverpod Trabalho posterior às Fases 1 e 2, fora da numeração M1.x/M2.x/M3.x do PRD. O diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 49bb6752..af2c2788 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -46,6 +46,8 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh` prova a permissão em runtime (não a chegada de um fix: o AVD não entrega posição) e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. + ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. diff --git a/docs/roteiro-video-sponsor.md b/docs/roteiro-video-sponsor.md index a11e27a4..23baed24 100644 --- a/docs/roteiro-video-sponsor.md +++ b/docs/roteiro-video-sponsor.md @@ -150,7 +150,7 @@ Todos disparam um `SnackBar` de "não integrado" e queimam a demonstração na f | Tela | Controle | Mensagem que aparece | |------|----------|----------------------| | `MapScreen` | "Traçar rota eficiente" | "Traçado de rota depende da integração de mapas." — e a própria tela imprime **"Mapa demonstrativo"** | -| `EscalationScreen` | "Ligar para o SAMU (192)" | "Discagem não está integrada neste protótipo." | +| `EscalationScreen` | "Ligar para o SAMU (192)" | abre o discador do aparelho com 192 (sem discador: "Ligue manualmente para 192") | | `EscalationScreen` | "Encaminhar para UBS Central" | "Encaminhamento será integrado à UBS." | | `GeofencingScreen` | "Abrir formulário da visita" | "Use a tela Visita para registrar o atendimento." | | `NoticesScreen` | "Preparar aviso" | "Envio depende da integração de notificações push." | diff --git a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md index 6119452a..3d06075d 100644 --- a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md +++ b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md @@ -297,6 +297,13 @@ implementação real de `PushTokenSource` no paciente (receptor). plano, não há geofence nativo registrado, e a permissão declarada no `AndroidManifest.xml` não inclui localização em background. +> **Correção (2026-10-01):** a permissão de localização em primeiro plano +> (`ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION`) **não estava** declarada no +> manifesto do ACS — medido com `aapt2` nos APKs de debug e de release —, então o +> GPS nunca chegava ao check-in num aparelho. Passou a estar, e +> `apps/acs/test/android_manifest_test.dart` impede a volta (e proíbe +> `ACCESS_BACKGROUND_LOCATION`). + ### Decisão aprovada **Rejeitar rastreamento contínuo em segundo plano do ACS.** Em vez de manter diff --git a/spec/validation_report.md b/spec/validation_report.md index ab2c789a..b2f0688e 100644 --- a/spec/validation_report.md +++ b/spec/validation_report.md @@ -86,7 +86,7 @@ cliente · **`parcial`** = existe, mas alimentado por dado fabricado · | RF10 | Mapa interativo | **parcial** | Coordenadas são **fabricadas** a partir do hash — ver L-05. | | RF11 | Registro rápido de visitas offline-first | **backend** | `visits.sync` com dedupe por `localId`, versionamento, conflito e território. Fila SQLCipher no dispositivo. | | RF12 | Geofencing (check-in passivo) | **ausente** | `RouteService` calcula chegada localmente, mas não há GPS em segundo plano. | -| RF13 | Escalonamento para SAMU/UBS | **ausente** | Ambos os botões são snackbars — ver L-07. | +| RF13 | Escalonamento para SAMU/UBS | **parcial** | O botão do SAMU abre o discador com 192 (`EmergencyDialer`, sem ligar sozinho; 2026-10-01). O da UBS segue sendo um aviso: não há fonte de contato da UBS. L-07 fechado só para o SAMU. | | RF14 | Avisos segmentados (push) | **ausente** | `NoticesScreen` descarta a entrada. Sem envio; contrato revisado para Gorush (ver `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2). | | RF15 | Sincronização bidirecional | **backend** | Dispositivo → central e central → dispositivo funcionam e são testados dos dois lados: ACS (`visits.pull`) e paciente (`alerts.statusFor`, RF05). Ambos rodam automaticamente ao abrir a tela, em ciclo periódico enquanto o app está em primeiro plano (ACS, em qualquer aba) ou enquanto a tela de Status está aberta (paciente), e por botão manual. Do lado ACS, o pull continua sendo só referência somente leitura (contagem exibida na tela), sem gravar as visitas puxadas na fila offline local — persistir esse resultado como registro local segue como trabalho futuro (ver nota em `VisitPullService`), fora do escopo deste plano. | | RF16 | Motor de triagem determinístico | **backend** | `TriageEngine`, determinismo verificado em teste de integração. INV-02 preservado. | @@ -272,7 +272,7 @@ mesmo vale para a TMRAV segmentada por risco, que é a métrica *North Star* do ciclo periódico. Ver `docs/superpowers/plans/2026-09-18-sync-periodica-rf05-l06.md`. RF08 continua `parcial` — ver linha RF08 acima. -- **L-07 · Escalonamento SAMU não funciona.** O botão mais crítico da UI de +- **L-07 · ~~Escalonamento SAMU não funciona.~~ (SAMU fechado em 2026-10-01; a UBS continua sem contato)** O botão mais crítico da UI de emergência é um snackbar. - **L-08 · ~~RPC sem TLS. O backend fala HTTP puro na 8080; só o broker usa TLS. RNF04 não é atendido.~~** RESOLVIDO em desenvolvimento — o RPC só é From 64a79f613192d68c9f2a298284f7d7364e871c64 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:15:55 -0400 Subject: [PATCH 08/43] =?UTF-8?q?docs:=20mensagem=20do=20e2e=20de=20GPS=20?= =?UTF-8?q?e=20plano=20n=C3=A3o=20afirmam=20mais=20do=20que=20foi=20provad?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-01-finalizacao-app-acs.md | 14 ++++++++++++++ scripts/qa/acs_gps_e2e.sh | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md b/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md index ae54a01b..8f9cb9c6 100644 --- a/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md +++ b/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md @@ -10,6 +10,20 @@ **Spec:** `spec/PRD_system.md` (RF07–RF13, §2.2), `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4 geofencing: só em primeiro plano, sem localização em background), `spec/lgpd_design.md`, `apps/CLAUDE.md`. +> **Desvios da execução (2026-10-01) — leia antes do resto.** O plano abaixo é o que +> foi escrito; o que foi entregue difere em dois pontos, ambos medidos: +> 1. **Task 3 não prova o fix de GPS.** Neste AVD (Android 16, Play Services) nem +> `adb emu geo fix`, nem provider de teste, nem `forceLocationManager` entregam +> posição ao app. O e2e prova a **permissão** em runtime (e falha com o manifesto +> antigo); "Local alcançado" com posição segue coberto só por testes com posição +> injetada. Onde o texto abaixo diz "GPS real"/`adb emu geo fix`, leia isto. +> 2. **Task 4 não cobre MQTT/ACK.** O `aclfile` do broker só libera o UUID de +> microárea do seed de dev e as fixtures de e2e são aleatórias. Onde o texto +> diz "alerta MQTT"/"ACK" no e2e do banco de teste, leia isto; o alerta pelo +> broker segue provado por `smoke`/`red_alert_cycle` na stack de dev. +> Também mudaram: nomes `*_e2e.dart`, `flutter drive` na Task 3, e um defeito do +> `dispose` do painel corrigido na Task 4. Os `Ruling:` completos estão no PROGRESS.md. + ## Estado verificado em 2026-10-01 Medido nesta sessão, não herdado de documento: diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh index 15123393..a2554383 100755 --- a/scripts/qa/acs_gps_e2e.sh +++ b/scripts/qa/acs_gps_e2e.sh @@ -47,4 +47,4 @@ flutter drive --driver=test_driver/integration_test.dart \ --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ --use-application-binary="$apk" \ --dart-define=EXPECT_PERMISSION="$expect" -echo "OK — check-in por GPS real ($expect)" +echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" From 6bdf7170acb262a1be8f836490f46b2719519d40 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:24:53 -0400 Subject: [PATCH 09/43] docs: corrige link relativo quebrado no plano do aceite do termo --- .../plans/2026-09-29-aceite-do-termo-no-login-otp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md b/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md index ca90619d..ea678671 100644 --- a/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md +++ b/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md @@ -685,7 +685,7 @@ Em `onboarding_endpoint.dart`, trocar o comentário "não há renovação silenc - [ ] **Step 2: apps/CLAUDE.md** -No parágrafo "The patient login is passwordless now (RF01)…", remover a frase "One known gap: the onboarding path (`onboarding.completeEnrollment`, RF02) still issues the 15-minute default and has nothing to renew from — registered with an owner in [PROGRESS.md](../PROGRESS.md). One stale comment comes with it: the doc of `isExpired` … not the role." e acrescentar no lugar: "Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app reads `myData()` and, when the latest `termsOfUse` consent row is not `granted` in `legalDocumentsVersion`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — \"Agora não\" and a failed `myData()` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it." +No parágrafo "The patient login is passwordless now (RF01)…", remover a frase "One known gap: the onboarding path (`onboarding.completeEnrollment`, RF02) still issues the 15-minute default and has nothing to renew from — registered with an owner in [PROGRESS.md](../../../PROGRESS.md). One stale comment comes with it: the doc of `isExpired` … not the role." e acrescentar no lugar: "Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app reads `myData()` and, when the latest `termsOfUse` consent row is not `granted` in `legalDocumentsVersion`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — \"Agora não\" and a failed `myData()` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it." - [ ] **Step 3: PROGRESS.md e spec** From dc35078034ce99642c66ca2ca57a692c38f9a526 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:38:09 -0400 Subject: [PATCH 10/43] =?UTF-8?q?docs:=20plano=20para=20fechar=20os=20mino?= =?UTF-8?q?rs=20da=20revis=C3=A3o=20do=20app=20ACS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-10-01-minors-da-revisao-do-app-acs.md | 937 ++++++++++++++++++ 1 file changed, 937 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md diff --git a/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md b/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md new file mode 100644 index 00000000..e431aec2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md @@ -0,0 +1,937 @@ +# Minors da revisão do app ACS — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar os 7 achados Minor adiados na revisão final do branch `fix/app_acs`, sem alterar o comportamento do app ACS: só scripts de QA, um teste de integração, o relé de OTP e um número num relatório. + +**Architecture:** Cada achado vira uma correção pequena com verificação que falha antes. Os scripts de shell ganham testes herméticos (adb/flutter falsos no `PATH`, no molde de `scripts/qa/ci_push_e2e_test.sh`); o relé ganha teste Python; a asserção de microárea passa a ser conferida também no servidor; a contagem do relatório ganha um conferidor. + +**Tech Stack:** bash, Python 3 (`unittest`), `adb`/`uiautomator`, Flutter `integration_test`/`flutter drive`, Docker Compose (`e2e_stack.sh`). + +**Spec:** `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md` (seção "Desvios da execução" e Tasks 3–4, que criaram o que aqui se corrige). A revisão que originou os 7 itens não está em disco; a tabela abaixo a resume. + +## Os 7 itens e onde são fechados + +| # | Achado da revisão | Task | +|---|---|---| +| 1 | `spec/validation_report.md:96` diz "3 `parcial` · 5 `ausente`"; a tabela tem 4 · 4 | 1 | +| 2 | A permissão negada não simula `deniedForever` (caso comum em campo) | 6 | +| 3 | `acs_gps_e2e.sh` desinstala o app do ACS sem avisar (apaga fila SQLCipher e Keystore de dev) | 5 | +| 4 | `acs_gps_e2e.sh` esconde o erro do `flutter build` (`>/dev/null`) | 5 | +| 5 | A asserção RNF06 do e2e depende de não haver filtro padrão no seletor | 4 | +| 6 | A senha sintética de `/acs` é legível por qualquer app do emulador durante a execução (≤ 45 min) | 3 | +| 7 | A checagem da porta 8765 só enxerga `127.0.0.1` | 2 | + +## Estado verificado em 2026-10-01 + +- `adb`, `flutter` e `emulator` não estão no `PATH`: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$HOME/flutter/bin"`. +- **`pm set-permission-flags` não existe** no AVD `Medium_Phone` (Android 16): `adb shell pm help` não o lista. A sugestão do revisor para o item 2 não funciona como escrita; a Task 6 recusa o diálogo do sistema de verdade e **mede** antes de depender disso. +- `Geolocator.checkPermission()` devolve `denied` (não `deniedForever`) num app que nunca pediu a permissão; só `requestPermission()` devolve `deniedForever` sem abrir diálogo quando a permissão está `USER_FIXED`. A Task 6 asserta sobre `requestPermission()`. +- Tabela de RF de `spec/validation_report.md`: 18 linhas (`RF01`–`RF18`); contadas por `grep`: 4 `parcial`, 4 `ausente` (o texto diz 3 · 5). +- O emulador pode ainda ter o APK de teste do ACS de execuções anteriores instalado. Depois da Task 5, a primeira rodada do script de GPS exige `--reinstalar` (é o comportamento novo, não um defeito). +- Os mesmos 3 scripts repetem a checagem `ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '`: `acs_full_e2e.sh:31`, `patient_full_e2e.sh:41`, `push_e2e.sh:93`. + +## Fora deste plano + +| Item | Motivo | +|---|---| +| Ligar os novos testes de shell/Python à CI | Exigiria mexer em `.github/workflows/ci.yml`, vigiado por `ci_invariants.sh`. Decisão à parte. | +| `iniciar_rele()` de `push_e2e.sh` | O revisor apontou que parece chamar a si mesma (defeito anterior ao branch). A Task 2 só troca a linha da checagem de porta; confira com `sed -n 90,102p scripts/qa/push_e2e.sh` e, se for mesmo recursiva, abra issue em vez de corrigir aqui. | +| Provar a chegada de um fix de GPS | Segue impossível neste AVD (ver o plano anterior). | + +## Global Constraints + +- Nenhum dado real de paciente em teste, log ou captura; só fixtures sintéticas. +- **Commits sem qualquer atribuição de IA** (`Co-Authored-By`, "Generated with Claude Code") — regra do `CLAUDE.md` da raiz. Textos, comentários e commits em português. Sem push nem merge. +- `ACCESS_BACKGROUND_LOCATION` continua proibida (não tocar o manifesto). +- A senha do ACS nunca vai para o argv do `flutter` nem para o APK (já vale; não regredir). +- `flutter analyze` em `apps/acs` sem problemas e `flutter test` em 208 (só pode crescer) antes de cada commit que toque Dart. +- `acs_full_e2e.sh` substitui a stack de desenvolvimento pela de e2e: ao final, `docker compose up -d` e conferir `sinalacs-serverpod ... (healthy)`. + +## Review Focus + +Falhas que o plano implica e nenhuma tarefa exercitaria sozinha; cada linha tem o teste dono: + +1. **App já instalado, sem `--reinstalar`:** o script aborta (exit 3) **antes** do build e sem nenhum `adb uninstall`. → Task 5, caso `guarda`. +2. **Falha no meio da execução** (build ok, `flutter drive` falha): o app de teste instalado pelo script é removido ao sair, para a próxima rodada não esbarrar na guarda. → Task 5, caso `drive_falha`. +3. **Porta 8765 ocupada em `0.0.0.0`/`[::]`** (não só `127.0.0.1`) é detectada. → Task 2. +4. **Segundo `GET /acs`** no mesmo relé devolve 404 (a senha sai uma vez só). → Task 3. +5. **Linha nova na tabela de RF com status fora da lista** (ex.: `**quase**`) faz o conferidor da contagem falhar, em vez de ser ignorada e deixar a contagem errada de novo. → Task 1. + +--- + +### Task 1: Conferidor da contagem do `validation_report.md` + +**Files:** +- Create: `scripts/qa/contagem_validation_report.py` +- Create: `scripts/qa/contagem_validation_report_test.py` +- Modify: `spec/validation_report.md:96` + +**Interfaces:** +- Consumes: nada. +- Produces: `contar(texto) -> dict[str,int]`, `conferir(texto) -> tuple[str,str]` (declarada, esperada) e o CLI `python3 scripts/qa/contagem_validation_report.py [arquivo]` (exit 0 confere; 1 diverge; 2 erro de formato). + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `scripts/qa/contagem_validation_report_test.py`: + +```python +import unittest + +from contagem_validation_report import conferir, contar + +TABELA = """\ +| RF01 | A | **backend** | x | +| RF02 | B | **parcial** | x | +| RF03 | C | **ausente** | x | +| RNF01 | D | **parcial** | x | + +**Contagem:** {linha}. +""" + + +class ContagemTest(unittest.TestCase): + def test_ignora_rnf_e_confere(self): + declarada, esperada = conferir(TABELA.format(linha="1 `backend` · 1 `parcial` · 1 `ausente`")) + self.assertEqual(declarada, esperada) + + def test_detecta_divergencia(self): + declarada, esperada = conferir(TABELA.format(linha="1 `backend` · 2 `parcial` · 1 `ausente`")) + self.assertNotEqual(declarada, esperada) + + def test_status_desconhecido_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF09 | Z | **quase** | x |\n") + + def test_sem_linha_de_contagem_e_erro(self): + with self.assertRaises(ValueError): + conferir("| RF01 | A | **backend** | x |\n") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd scripts/qa && python3 contagem_validation_report_test.py` +Expected: FAIL — `ModuleNotFoundError: No module named 'contagem_validation_report'`. + +- [ ] **Step 3: Implementação mínima** + +Criar `scripts/qa/contagem_validation_report.py` (`chmod +x`): + +```python +#!/usr/bin/env python3 +"""Confere a linha "**Contagem:**" de spec/validation_report.md contra a tabela de RF. + +A linha foi escrita à mão e ficou para trás quando RF13 mudou de status; este +conferidor recalcula a partir das linhas `| RFnn | ... | **status** | ... |`. +Status fora da lista conhecida é erro (exit 2), não linha ignorada: ignorar +deixaria a contagem errada de novo, em silêncio. +""" +import pathlib +import re +import sys + +ORDEM = ["backend", "backend + app", "app-only", "parcial", "ausente"] +LINHA_RF = re.compile(r"^\| RF\d+ \|[^|]*\| \*\*([^*]+)\*\* \|", re.M) +LINHA_CONTAGEM = re.compile(r"^\*\*Contagem:\*\* (.+)\.$", re.M) + + +def contar(texto): + contagem = {chave: 0 for chave in ORDEM} + for status in LINHA_RF.findall(texto): + if status not in contagem: + raise ValueError(f"status desconhecido na tabela de RF: {status!r}") + contagem[status] += 1 + return contagem + + +def esperada(contagem): + return " · ".join(f"{contagem[chave]} `{chave}`" for chave in ORDEM if contagem[chave]) + + +def conferir(texto): + declarada = LINHA_CONTAGEM.search(texto) + if declarada is None: + raise ValueError("sem linha '**Contagem:**'") + return declarada.group(1), esperada(contar(texto)) + + +if __name__ == "__main__": + caminho = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "spec/validation_report.md") + try: + declarada_, esperada_ = conferir(caminho.read_text(encoding="utf-8")) + except ValueError as erro: + print(f"erro: {erro}", file=sys.stderr) + sys.exit(2) + if declarada_ != esperada_: + print( + f"erro: contagem desatualizada em {caminho}\n declarada: {declarada_}\n da tabela: {esperada_}", + file=sys.stderr, + ) + sys.exit(1) + print("ok: contagem do validation_report confere") +``` + +- [ ] **Step 4: Rodar o teste e ver passar; rodar o conferidor e ver o defeito real** + +Run: `cd scripts/qa && python3 contagem_validation_report_test.py && cd ../.. && python3 scripts/qa/contagem_validation_report.py` +Expected: `OK` (4 testes) e o conferidor **falha** (exit 1) mostrando `declarada: 7 \`backend\` · 2 \`backend + app\` · 1 \`app-only\` · 3 \`parcial\` · 5 \`ausente\`` e `da tabela: …· 4 \`parcial\` · 4 \`ausente\``. Se algum dos três primeiros números da linha "da tabela" divergir de 7 · 2 · 1, **investigue a tabela antes de editar** — não aceite o valor às cegas. + +- [ ] **Step 5: Corrigir o relatório** + +Em `spec/validation_report.md:96`, trocar `3 \`parcial\` · 5 \`ausente\`` por `4 \`parcial\` · 4 \`ausente\``. + +Run: `python3 scripts/qa/contagem_validation_report.py` +Expected: `ok: contagem do validation_report confere`. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa/contagem_validation_report.py scripts/qa/contagem_validation_report_test.py spec/validation_report.md +git commit -m "docs: corrige a contagem do validation_report e adiciona conferidor" +``` + +--- + +### Task 2: Checagem de porta 8765 em qualquer endereço + +**Files:** +- Create: `scripts/qa/lib_rele.sh` +- Create: `scripts/qa/lib_rele_test.sh` +- Modify: `scripts/qa/acs_full_e2e.sh:31`, `scripts/qa/patient_full_e2e.sh:41`, `scripts/qa/push_e2e.sh:93` + +**Interfaces:** +- Consumes: nada. +- Produces: `porta_ocupada ` (função bash; exit 0 se alguém escuta na porta TCP em **qualquer** endereço). + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `scripts/qa/lib_rele_test.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# Teste hermético de scripts/qa/lib_rele.sh (porta_ocupada) e de que os três +# runners do relé a usam. Não precisa de emulador nem de Docker. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +# shellcheck disable=SC1091 +source scripts/qa/lib_rele.sh +falhas=0 +confere() { "$@" || { echo "FALHOU: $*"; falhas=$((falhas + 1)); }; } +nega() { if "$@"; then echo "FALHOU (esperava falso): $*"; falhas=$((falhas + 1)); fi; } + +porta="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')" +nega porta_ocupada "$porta" + +for endereco in 127.0.0.1 0.0.0.0 ::; do + python3 -c ' +import socket, sys, time +familia = socket.AF_INET6 if ":" in sys.argv[1] else socket.AF_INET +s = socket.socket(familia) +s.bind((sys.argv[1], int(sys.argv[2]))) +s.listen() +time.sleep(30)' "$endereco" "$porta" 2>/dev/null & + pid=$! + for _ in $(seq 1 20); do porta_ocupada "$porta" && break; sleep 0.1; done + if kill -0 "$pid" 2>/dev/null; then + confere porta_ocupada "$porta" + else + echo "pulado: este host não consegue escutar em $endereco" + fi + kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null + for _ in $(seq 1 20); do porta_ocupada "$porta" || break; sleep 0.1; done +done + +for f in acs_full_e2e.sh patient_full_e2e.sh push_e2e.sh; do + nega grep -qF "grep -q '127.0.0.1:8765 '" "scripts/qa/$f" + confere grep -q "porta_ocupada 8765" "scripts/qa/$f" +done + +[[ "$falhas" -eq 0 ]] && echo "ok: lib_rele" || { echo "$falhas falha(s)"; exit 1; } +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `./scripts/qa/lib_rele_test.sh` +Expected: FAIL — `source: scripts/qa/lib_rele.sh: No such file or directory` (e, sem `set -e`, as asserções seguintes também falham). + +- [ ] **Step 3: Implementação mínima** + +Criar `scripts/qa/lib_rele.sh`: + +```bash +# Funções compartilhadas pelos runners que sobem o relé do OTP (otp_relay.py). +# Uso: source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" + +# Sucesso (0) se ALGUM processo escuta na porta TCP $1, em qualquer endereço +# (127.0.0.1, 0.0.0.0, [::] ou o IP da rede). Um relé antigo esquecido num +# deles responderia com código velho; olhar só 127.0.0.1 deixava passar os +# outros e o erro só aparecia no bind do relé novo, sem dizer o motivo. +porta_ocupada() { + ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN +} +``` + +Trocar nos três scripts, com um passo único (cada arquivo tem exatamente uma ocorrência): + +```bash +python3 - <<'EOF' +import pathlib +for nome in ("acs_full_e2e.sh", "patient_full_e2e.sh", "push_e2e.sh"): + p = pathlib.Path("scripts/qa") / nome + s = p.read_text() + antigo = "if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then" + assert s.count(antigo) == 1, f"{nome}: esperava 1 ocorrência" + s = s.replace(antigo, "if porta_ocupada 8765; then") + marca = "set -euo pipefail\n" + assert marca in s, f"{nome}: sem 'set -euo pipefail'" + s = s.replace(marca, marca + 'source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh"\n', 1) + p.write_text(s) +EOF +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `./scripts/qa/lib_rele_test.sh && for f in acs_full_e2e patient_full_e2e push_e2e; do bash -n scripts/qa/$f.sh && echo "sintaxe ok: $f"; done` +Expected: `ok: lib_rele` (linhas "pulado" são aceitáveis) e três `sintaxe ok`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/lib_rele.sh scripts/qa/lib_rele_test.sh scripts/qa/acs_full_e2e.sh scripts/qa/patient_full_e2e.sh scripts/qa/push_e2e.sh +git commit -m "fix(qa): checagem da porta 8765 enxerga qualquer endereço, não só 127.0.0.1" +``` + +--- + +### Task 3: Senha do ACS servida uma única vez pelo relé + +**Files:** +- Modify: `scripts/qa/otp_relay.py` (cabeçalho, `Handler`, rota `/acs`) +- Modify: `scripts/qa/otp_relay_test.py` + +**Interfaces:** +- Consumes: `acs_do_manifesto(caminho)` e `Handler` de `otp_relay.py` (Task 4 do plano anterior). +- Produces: `Handler.acs_entregue: bool` (atributo de classe; `GET /acs` responde 200 uma vez por processo e 404 depois). + +- [ ] **Step 1: Escrever o teste que falha** + +Acrescentar a `scripts/qa/otp_relay_test.py`, antes do `if __name__ == "__main__":`: + +```python +class AcsEntregaUnicaTest(unittest.TestCase): + """Qualquer app do emulador alcança localhost:8765 (adb reverse): a senha + sintética do ACS só pode sair uma vez por execução do relé.""" + + def setUp(self): + import json + import os + import tempfile + import threading + from http.server import HTTPServer + + import otp_relay + + self.otp_relay = otp_relay + arquivo = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + json.dump({"acs": {"matricula": "E2E-1", "password": "s"}}, arquivo) + arquivo.close() + self.caminho = arquivo.name + os.environ["E2E_FIXTURES_FILE"] = self.caminho + otp_relay.Handler.acs_entregue = False + self.servidor = HTTPServer(("127.0.0.1", 0), otp_relay.Handler) + otp_relay.Handler.porta = self.servidor.server_address[1] + threading.Thread(target=self.servidor.serve_forever, daemon=True).start() + + def tearDown(self): + import os + + self.servidor.shutdown() + self.servidor.server_close() + os.environ.pop("E2E_FIXTURES_FILE", None) + os.unlink(self.caminho) + self.otp_relay.Handler.porta = 8765 + self.otp_relay.Handler.acs_entregue = False + + def _get(self): + import urllib.error + import urllib.request + + url = f"http://127.0.0.1:{self.servidor.server_address[1]}/acs" + try: + with urllib.request.urlopen(url) as resposta: + return resposta.status + except urllib.error.HTTPError as erro: + return erro.code + + def test_serve_uma_vez_e_depois_404(self): + self.assertEqual(self._get(), 200) + self.assertEqual(self._get(), 404) +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd scripts/qa && python3 otp_relay_test.py AcsEntregaUnicaTest` +Expected: FAIL — `AssertionError: 200 != 404` (o segundo pedido ainda recebe a senha). + +- [ ] **Step 3: Implementação mínima** + +Em `scripts/qa/otp_relay.py`: + +1. No docstring do módulo, acrescentar o parágrafo: + +``` +`/acs` (só com E2E_FIXTURES_FILE) entrega a matrícula e a senha SINTÉTICAS do ACS +da execução, **uma única vez por processo**: qualquer app do emulador que alcance +localhost:8765 (adb reverse) poderia lê-las, então depois do primeiro pedido bem +sucedido — o do teste, no começo — a rota responde 404. O relé é por execução. +``` + +2. Em `class Handler`, junto de `porta` e `container`: + +```python + acs_entregue = False +``` + +3. Na rota `/acs`, trocar a condição e marcar a entrega: + +```python + if url.path == "/acs": + credencial = acs_do_manifesto(os.environ.get("E2E_FIXTURES_FILE")) + if credencial is None or Handler.acs_entregue: + self.send_error(404); return + Handler.acs_entregue = True + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write(json.dumps(credencial).encode()) + return +``` + +- [ ] **Step 4: Rodar e ver passar (suíte inteira do relé)** + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: `OK` (os 9 testes anteriores + 1 novo = 10). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/otp_relay.py scripts/qa/otp_relay_test.py +git commit -m "fix(qa): relé serve a senha sintética do ACS uma única vez por execução" +``` + +--- + +### Task 4: Asserção de microárea (RNF06) conferida também no servidor + +**Files:** +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (seletor e checagem final) + +**Interfaces:** +- Consumes: `acsCredentialFromRelay()`, `e2ePatient(role)` de `support/e2e_acs.dart`; `acsClient` (cliente separado, já criado no fim do teste); `api.Client.patients.listMicroArea({required String accessToken}) → Future>`; Task 3 (o `acs_full_e2e.sh` lê `/acs` uma vez só). +- Produces: nada além do teste. + +A UI esconde o paciente de fora por filtro de tela *e* por regra do servidor; a regra é do servidor. A asserção passa a olhar o servidor (e a tela passa a provar que o filtro de busca está vazio, para o `findsOneWidget` do paciente da microárea não ser enganado por um filtro). + +- [ ] **Step 1: Escrever a asserção (invertida) e ver que o teste a pega** + +Em `apps/acs/integration_test/full_journey_e2e.dart`, logo depois do `final acs = await acsClient.auth.loginInstitutional(...)`, acrescentar: + +```dart + // RNF06 no servidor: a lista da microárea nunca traz o paciente de fora. + // Quem garante é o backend, não o filtro do seletor. + final daMicroarea = await acsClient.patients.listMicroArea(accessToken: acs.accessToken); + expect(daMicroarea.any((p) => p.patientId == main.id), isTrue); + expect(daMicroarea.any((p) => p.patientId == outsider.id), isTrue, reason: 'MUTAÇÃO TEMPORÁRIA: deve falhar'); +``` + +(`isTrue` em `outsider` é **de propósito**: prova que a asserção morde.) + +Run: `./scripts/qa/acs_full_e2e.sh` +Expected: FAIL com `MUTAÇÃO TEMPORÁRIA: deve falhar` (e o manifesto `.e2e/fixtures.json` apagado pelo `cleanup`). + +- [ ] **Step 2: Corrigir a asserção e provar o filtro vazio na tela** + +Trocar a linha mutada por: + +```dart + expect(daMicroarea.any((p) => p.patientId == outsider.id), isFalse, reason: 'outra microárea'); +``` + +E, no trecho do seletor (logo depois de `expect(find.byKey(Key('patient_${main.id}')), findsOneWidget);`), acrescentar: + +```dart + // O seletor não pode estar filtrado: sem isto, "o de fora não aparece" + // poderia ser só um filtro por nome escondendo tudo o que não casa. + final busca = tester.widget(find.byKey(const Key('patient_search'))); + expect(busca.controller!.text, isEmpty, reason: 'o seletor deve abrir sem filtro de busca'); +``` + +- [ ] **Step 3: Rodar e ver passar** + +Run: `cd apps/acs && flutter analyze integration_test/full_journey_e2e.dart; cd ../.. && ./scripts/qa/acs_full_e2e.sh` +Expected: `No issues found!` e `OK — jornada completa do ACS contra o banco de teste`. Esta execução também exercita as Tasks 2 e 3 (porta e `/acs` de uso único). + +- [ ] **Step 3b: Devolver a stack de desenvolvimento** + +Run: `docker compose up -d && docker compose ps --format '{{.Name}} {{.Status}}' | grep serverpod` +Expected: `sinalacs-serverpod … (healthy)`; `ls .e2e/fixtures.json` → `No such file`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/acs/integration_test/full_journey_e2e.dart +git commit -m "test(acs): confere RNF06 também no servidor e exige seletor sem filtro" +``` + +--- + +### Task 5: Script de GPS — guarda de reinstalação, erro do build visível, limpeza ao sair + +**Files:** +- Create: `scripts/qa/acs_gps_e2e_test.sh` +- Modify: `scripts/qa/acs_gps_e2e.sh` (reescrita do corpo; cabeçalho mantido e ampliado) + +**Interfaces:** +- Consumes: `integration_test/geofence_gps_e2e.dart` e `test_driver/integration_test.dart` (já existem). +- Produces: `acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` com exit 0 = ok, 2 = argumento inválido, 3 = app já instalado sem `--reinstalar`, 4 = sem emulador, 1 = build/teste falhou; `MQTT_ACS_PASSWORD` pode vir do ambiente (o `.env` passa a ser opcional). O script **remove o app de teste ao sair** se foi ele quem o instalou. + +- [ ] **Step 1: Escrever o teste hermético que falha** + +Criar `scripts/qa/acs_gps_e2e_test.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# Teste hermético de scripts/qa/acs_gps_e2e.sh: adb e flutter FALSOS no PATH. +# Não precisa de emulador, de Docker nem do .env (a senha vai pelo ambiente). +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +mkdir "$tmp/bin" +export FAKE_LOG="$tmp/chamadas.log" + +cat >"$tmp/bin/adb" <<'EOF' +#!/usr/bin/env bash +echo "adb $*" >>"$FAKE_LOG" +case "$*" in + *get-state*) echo device ;; + *"pm list packages"*) [[ "${FAKE_INSTALADO:-0}" == 1 ]] && echo "package:br.com.prismrr.sinalacs.acs" ;; + *"dumpsys window"*) [[ "${FAKE_DIALOGO:-0}" == 1 ]] && echo "mCurrentFocus=Window{1 u0 com.google.android.permissioncontroller/x.GrantPermissionsActivity}" ;; + *"dumpsys package"*) [[ "${FAKE_FIXADA:-0}" == 1 ]] && echo "android.permission.ACCESS_FINE_LOCATION: granted=false, flags=[ USER_SET|USER_FIXED ]" ;; +esac +exit 0 +EOF +cat >"$tmp/bin/flutter" <<'EOF' +#!/usr/bin/env bash +echo "flutter $*" >>"$FAKE_LOG" +case "$1" in + build) [[ "${FAKE_BUILD_FALHA:-0}" == 1 ]] && { echo "ERRO-FALSO-DO-GRADLE: sem espaço" >&2; exit 1; } ;; + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 ;; +esac +exit 0 +EOF +chmod +x "$tmp/bin/"* + +falhas=0 +caso() { # caso [VAR=valor ...] -- [args do script] + nome=$1; shift + envs=(); while [[ $# -gt 0 && $1 != -- ]]; do envs+=("$1"); shift; done; shift + : >"$FAKE_LOG" + saida="$(env PATH="$tmp/bin:$PATH" MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? +} +verifica() { eval "$1" || { echo "FALHOU [$nome]: $1"; echo "$saida" | tail -4; falhas=$((falhas + 1)); }; } +conta() { grep -c "$1" "$FAKE_LOG" || true; } + +caso guarda FAKE_INSTALADO=1 -- +verifica '[[ $codigo -eq 3 ]]' +verifica 'grep -q "APAGA" <<<"$saida"' +verifica '[[ $(conta uninstall) -eq 0 ]]' +verifica '[[ $(conta "^flutter build") -eq 0 ]]' + +caso build_falha FAKE_BUILD_FALHA=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica 'grep -q "o build do APK falhou" <<<"$saida"' +verifica 'grep -q "ERRO-FALSO-DO-GRADLE" <<<"$saida"' +verifica '[[ $(conta "install -r") -eq 0 ]]' + +caso reinstalar FAKE_INSTALADO=1 -- --reinstalar +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta uninstall) -eq 2 ]]' # antes de instalar e ao sair + +caso drive_falha FAKE_DRIVE_FALHA=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica '[[ $(conta uninstall) -eq 1 ]]' # o app de teste não fica para a próxima rodada + +caso ok -- +verifica '[[ $codigo -eq 0 ]]' +verifica 'grep -q "fix de GPS não verificado" <<<"$saida"' + +caso argumento_invalido -- --nao-existe +verifica '[[ $codigo -eq 2 ]]' + +[[ "$falhas" -eq 0 ]] && echo "ok: acs_gps_e2e" || { echo "$falhas falha(s)"; exit 1; } +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `./scripts/qa/acs_gps_e2e_test.sh` +Expected: FAIL em vários casos — `guarda` (o script atual desinstala sem perguntar e sai 0), `build_falha` (sem mensagem), `reinstalar`/`drive_falha` (contagem de `uninstall` errada) e `argumento_invalido`. + +- [ ] **Step 3: Implementação** + +Reescrever `scripts/qa/acs_gps_e2e.sh` (mantendo o cabeçalho de comentário atual e acrescentando, depois dele, o parágrafo abaixo). O corpo: + +```bash +# GUARDA: se o app do ACS já estiver instalado no emulador, o script aborta +# (exit 3) — reinstalar APAGA a fila de visitas offline (SQLCipher) e a chave do +# Keystore do app de desenvolvimento. `--reinstalar` autoriza. O script é dono +# do pacote enquanto roda e o remove ao sair, para a próxima rodada não esbarrar +# na própria guarda. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" +cd apps/acs + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +apk=build/app/outputs/flutter-apk/app-debug.apk +expect=granted +reinstalar=0 +for arg in "$@"; do + case "$arg" in + --sem-permissao) expect=denied ;; + --reinstalar) reinstalar=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +instalado() { adb -s "$dev" shell pm list packages "$pkg" 2>/dev/null | grep -q "^package:$pkg\$"; } +if instalado && [[ "$reinstalar" -eq 0 ]]; then + echo "erro: $pkg já está instalado em $dev." >&2 + echo " Reinstalar APAGA a fila de visitas offline (SQLCipher) e a chave do Keystore deste aparelho." >&2 + echo " Se for só o app de teste de uma execução anterior, rode de novo com --reinstalar." >&2 + exit 3 +fi + +log="$(mktemp)" +instalamos=0 +limpar() { + rm -f "$log" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT + +if ! flutter build apk --debug --target=integration_test/geofence_gps_e2e.dart \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=EXPECT_PERMISSION="$expect" >"$log" 2>&1; then + echo "erro: o build do APK falhou. Últimas linhas:" >&2 + tail -n 30 "$log" >&2 + exit 1 +fi +if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +adb -s "$dev" install -r "$apk" >/dev/null +instalamos=1 +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + if [[ "$expect" == granted ]]; then + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + else + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + fi +done + +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" \ + --dart-define=EXPECT_PERMISSION="$expect" +echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" +``` + +Também atualizar a linha de uso do cabeçalho: `# ./scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]`. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `bash -n scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e_test.sh` +Expected: `ok: acs_gps_e2e`. + +- [ ] **Step 5: Provar no emulador (concedida) e ver a guarda real** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +./scripts/qa/acs_gps_e2e.sh; echo "exit=$?" # há APK de teste de rodadas anteriores: deve abortar com 3 +./scripts/qa/acs_gps_e2e.sh --reinstalar; echo "exit=$?" +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs | wc -l +``` +Expected: primeiro `exit=3` com a mensagem "APAGA…" (se o emulador estiver sem o app, `exit=0` — então rode só o segundo); depois `OK — permissão de localização em runtime (granted); fix de GPS não verificado`, `exit=0`, e o último comando imprime `0` (app removido ao sair). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa/acs_gps_e2e.sh scripts/qa/acs_gps_e2e_test.sh +git commit -m "fix(qa): script de GPS avisa antes de apagar o app, mostra o erro do build e limpa ao sair" +``` + +--- + +### Task 6: Permissão "negada de vez" (`deniedForever`) no e2e de GPS + +**Files:** +- Modify: `scripts/qa/acs_gps_e2e.sh` (negar de vez, amostrador de diálogo) +- Modify: `scripts/qa/acs_gps_e2e_test.sh` (casos novos) +- Modify: `apps/acs/integration_test/geofence_gps_e2e.dart` (modos `denied_forever` e `prime`) + +**Interfaces:** +- Consumes: Task 5 (estrutura do script, `limpar`, `instalado`, `FAKE_*` do teste). +- Produces: `--sem-permissao` passa a significar "negada de vez" (`USER_FIXED`), o estado de um ACS que já recusou duas vezes; `EXPECT_PERMISSION` ∈ `granted | denied_forever | prime`; exit 5 = não foi possível levar a permissão a `USER_FIXED`; o script falha (exit 1) se o diálogo de permissão do sistema aparecer por cima do app. + +**Por que não `pm set-permission-flags`:** não existe neste Android (ver "Estado verificado"). O caminho é recusar o diálogo do sistema duas vezes com `uiautomator` + `input tap` (no Android 11+, dois "Não permitir" tornam a negação permanente), e **conferir** o resultado em `dumpsys package` (`granted=false` com `USER_FIXED`). O cenário antigo (revogada mas ainda pedível) é descartado: deixava o diálogo aberto por cima do app, e o teste de widget do Flutter nem percebia. + +- [ ] **Step 1: Medir os ids reais dos botões do diálogo** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +./scripts/qa/acs_gps_e2e.sh --sem-permissao --reinstalar > /tmp/claude-1000/gps_med.log 2>&1 & +sleep 40 # (medição manual, uma vez) — com o diálogo na tela: +adb -s emulator-5554 exec-out uiautomator dump /dev/tty | grep -o 'resource-id="com.android.permissioncontroller[^"]*"' | sort -u +``` +Expected: uma lista contendo `permission_deny_button` e/ou `permission_deny_and_dont_ask_again_button`. Se os ids forem **outros**, troque os dois nomes em `tocar_recusar` (Step 3) pelos medidos, e registre no commit. Se o diálogo não aparecer (nenhum `resource-id` do `permissioncontroller`), vá para **Step 8 (fallback)**. + +- [ ] **Step 2: Escrever os testes herméticos que falham** + +Em `scripts/qa/acs_gps_e2e_test.sh`, antes da linha `[[ "$falhas" -eq 0 ]] && …`, acrescentar: + +```bash +caso dialogo FAKE_DIALOGO=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica 'grep -q "diálogo de permissão" <<<"$saida"' + +caso sem_permissao FAKE_FIXADA=1 -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "pm revoke") -eq 2 ]]' +verifica 'grep -q "EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' +verifica '[[ $(conta "EXPECT_PERMISSION=prime") -eq 0 ]]' # já fixada: não precisa recusar de novo + +caso nao_fixa FAKE_FIXADA=0 -- --sem-permissao +verifica '[[ $codigo -eq 5 ]]' +verifica 'grep -q "USER_FIXED" <<<"$saida"' +verifica '[[ $(conta "EXPECT_PERMISSION=prime") -eq 3 ]]' +``` + +Run: `./scripts/qa/acs_gps_e2e_test.sh` +Expected: FAIL nos três casos novos (o script ainda não tem amostrador, nem `denied_forever`, nem exit 5). + +- [ ] **Step 3: Implementar no script** + +Em `scripts/qa/acs_gps_e2e.sh`: + +1. `--sem-permissao) expect=denied_forever ;;`. + +2. Antes do bloco `for p in ACCESS_FINE_LOCATION …`, definir as funções: + +```bash +# "Negada de vez" = granted=false com USER_FIXED (o app não consegue mais abrir o diálogo). +negada_de_vez() { + adb -s "$dev" shell dumpsys package "$pkg" | grep -Eq 'ACCESS_FINE_LOCATION: granted=false, flags=\[[^]]*USER_FIXED' +} + +# Toca em "Não permitir" no diálogo de permissão do sistema, se ele estiver na tela. +tocar_recusar() { + local xml xy + xml="$(adb -s "$dev" exec-out uiautomator dump /dev/tty 2>/dev/null || true)" + xy="$(python3 - "$xml" <<'PY' +import re, sys +xml = sys.argv[1] +for rid in ("permission_deny_and_dont_ask_again_button", "permission_deny_button"): + m = re.search(r'resource-id="com\.android\.permissioncontroller:id/%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % rid, xml) + if m: + x1, y1, x2, y2 = map(int, m.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + break +PY +)" + if [[ -n "$xy" ]]; then adb -s "$dev" shell input tap $xy; fi + return 0 +} + +# Não há `pm set-permission-flags` neste Android (medido): a única forma de chegar a +# USER_FIXED é recusar o diálogo do sistema de verdade. Roda o teste em modo `prime` +# (ele só abre o painel e deixa o app pedir a permissão) com um tocador recusando. +negar_de_vez() { + local tentativa tocador + for tentativa in 1 2 3; do + if negada_de_vez; then return 0; fi + ( while true; do tocar_recusar; sleep 1; done ) & + tocador=$! + flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" --dart-define=EXPECT_PERMISSION=prime || true + kill "$tocador" 2>/dev/null || true + done + negada_de_vez +} +``` + +3. O bloco de permissões passa a ser: + +```bash +if [[ "$expect" == granted ]]; then + for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + done +else + for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + done + negar_de_vez || { echo "erro: não consegui levar a permissão a USER_FIXED (negada de vez) em 3 tentativas." >&2; exit 5; } +fi +``` + +4. Amostrador do diálogo e o `drive` final (substituindo o `flutter drive` e o `echo` finais). Declarar `amostras=""` e `amostrador=""` junto de `instalamos=0`, e estender `limpar()`: + +```bash +limpar() { + rm -f "$log" "$amostras" + if [[ -n "$amostrador" ]]; then kill "$amostrador" 2>/dev/null || true; fi + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +``` + +e no fim do script: + +```bash +# O teste de widget do Flutter injeta toques no próprio Flutter e NÃO percebe um diálogo +# do sistema aberto por cima do app. Quem percebe é o foco da janela, amostrado durante a execução. +amostras="$(mktemp)" +amostrar() { adb -s "$dev" shell dumpsys window 2>/dev/null | grep -m1 mCurrentFocus >>"$amostras" || true; } +( while true; do amostrar; sleep 1; done ) & +amostrador=$! + +drive_rc=0 +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" \ + --dart-define=EXPECT_PERMISSION="$expect" || drive_rc=$? +amostrar # uma leitura final garante ao menos uma amostra mesmo num teste curto +kill "$amostrador" 2>/dev/null || true; amostrador="" +if grep -q permissioncontroller "$amostras"; then + echo "erro: o diálogo de permissão do sistema apareceu por cima do app durante o teste." >&2 + exit 1 +fi +if [[ "$drive_rc" -ne 0 ]]; then exit "$drive_rc"; fi +echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" +``` + +(Ajustar `limpar` do Task 5: o `amostras="$(mktemp)"` fica antes do `trap`, no mesmo ponto em que `log` é criado, para o `rm -f` funcionar mesmo em saída antecipada: declarar `amostras=""`/`amostrador=""` no topo e atribuir `amostras` ao chegar nesse bloco.) + +- [ ] **Step 4: Rodar os testes herméticos** + +Run: `bash -n scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e_test.sh` +Expected: `ok: acs_gps_e2e` (os casos da Task 5 continuam verdes; o caso `nao_fixa` leva ~3 s por causa do `sleep 1` do tocador). + +- [ ] **Step 5: Ajustar o teste Dart aos modos novos** + +Em `apps/acs/integration_test/geofence_gps_e2e.dart`: + +1. Atualizar o comentário da constante e o bloco da permissão. Trocar o `if (_expect == 'granted') {…} else {…}` inicial por: + +```dart + final permission = await Geolocator.checkPermission(); + if (_expect == 'granted') { + expect(permission, anyOf(LocationPermission.whileInUse, LocationPermission.always), + reason: 'o manifesto precisa declarar ACCESS_FINE_LOCATION para a permissão existir'); + } else if (_expect == 'denied_forever') { + // Negada de vez (USER_FIXED): o `requestPermission` volta na hora, sem diálogo. + // (`checkPermission` sozinho devolve `denied`: só o pedido distingue o "de vez".) + expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + expect(await Geolocator.requestPermission(), LocationPermission.deniedForever); + } + // `prime`: não asserta nada sobre a permissão; só deixa o app pedi-la para o script + // do emulador recusar o diálogo (ver scripts/qa/acs_gps_e2e.sh, `negar_de_vez`). +``` + +2. Trocar `if (_expect == 'denied') {` (mais abaixo) por `if (_expect == 'denied_forever') {`. + +3. No comentário do cabeçalho, trocar "concede ou revoga a permissão por `pm`" por "concede por `pm` ou leva a permissão a negada-de-vez recusando o diálogo". + +Run: `cd apps/acs && flutter analyze integration_test/geofence_gps_e2e.dart` +Expected: `No issues found!`. + +- [ ] **Step 6: Provar no emulador — negada de vez** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +./scripts/qa/acs_gps_e2e.sh --sem-permissao --reinstalar; echo "exit=$?" +``` +Expected: `OK — permissão de localização em runtime (denied_forever); fix de GPS não verificado`, `exit=0`. Se sair `exit=5`, o tocador não recusou o diálogo: volte ao Step 1 (ids) ou ao Step 8. + +Prova contrária (RED real do cenário antigo): antes de implementar o Step 3, rodar a versão da Task 5 com `--sem-permissao --reinstalar` **mais o amostrador** mostra `erro: o diálogo de permissão do sistema apareceu…` (o cenário antigo deixava o diálogo aberto); depois do Step 3 o amostrador não vê o diálogo. + +- [ ] **Step 7: Confirmar a concedida e commitar** + +```bash +./scripts/qa/acs_gps_e2e.sh --reinstalar; echo "exit=$?" +git add scripts/qa/acs_gps_e2e.sh scripts/qa/acs_gps_e2e_test.sh apps/acs/integration_test/geofence_gps_e2e.dart +git commit -m "test(acs): permissão negada de vez e checagem de diálogo do sistema no e2e de GPS" +``` +Expected: `OK — … (granted) …`, `exit=0`. + +- [ ] **Step 8: Fallback (só se o Step 1 ou o Step 6 mostrarem que recusar o diálogo não funciona neste AVD)** + +Não é "pular o item": registre a decisão. Reverter `negar_de_vez`/`tocar_recusar`/`prime` (`git checkout -- scripts/qa/acs_gps_e2e.sh apps/acs/integration_test/geofence_gps_e2e.dart`), **manter** o amostrador de diálogo e os casos `dialogo` do teste, voltar `--sem-permissao` ao `pm revoke` e documentar no cabeçalho de `geofence_gps_e2e.dart` e no `PROGRESS.md`: "o caso `deniedForever` não é simulável neste AVD: `pm set-permission-flags` não existe e o diálogo não pôde ser recusado por `uiautomator` (medido em AAAA-MM-DD, motivo: …); coberto apenas por revisão". Commit: `docs(acs): registra o limite do cenário deniedForever no AVD`. + +--- + +### Task 7: Documentação e barra final + +**Files:** +- Modify: `PROGRESS.md` (seção "Finalização do app ACS (2026-10-01)") +- Modify: `apps/CLAUDE.md` (parágrafo "ACS: permissões, SAMU e e2e") + +**Interfaces:** consome as Tasks 1–6. + +- [ ] **Step 1: Atualizar os textos** + +- `PROGRESS.md`, na seção "Finalização do app ACS (2026-10-01)", acrescentar um item "**Minors da revisão — fechados:**" listando os 7 (contagem do relatório com conferidor `scripts/qa/contagem_validation_report.py`; `deniedForever` simulado por recusa real do diálogo — ou o fallback, se foi o caso —; o script de GPS aborta se o app já está instalado e exige `--reinstalar`, mostra o erro do build e remove o app de teste ao sair; RNF06 conferido também por `patients.listMicroArea` e com seletor sem filtro; `/acs` servido uma vez por execução; checagem de porta em qualquer endereço via `scripts/qa/lib_rele.sh`). +- `apps/CLAUDE.md`, no parágrafo "ACS: permissões, SAMU e e2e", trocar a frase de `acs_gps_e2e.sh` por: "`scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (`USER_FIXED`), sempre com checagem de que o diálogo do sistema não aparece — e aborta se o app já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix". Acrescentar: "O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução." + +- [ ] **Step 2: Barra final** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +python3 scripts/qa/contagem_validation_report.py +(cd scripts/qa && python3 contagem_validation_report_test.py && python3 otp_relay_test.py) +./scripts/qa/lib_rele_test.sh && ./scripts/qa/acs_gps_e2e_test.sh +(cd apps/acs && flutter analyze && flutter test) +./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh +./scripts/qa/acs_gps_e2e.sh --reinstalar && ./scripts/qa/acs_gps_e2e.sh --sem-permissao --reinstalar +./scripts/qa/acs_full_e2e.sh; docker compose up -d +``` +Expected: todos verdes: conferidor `ok`; Python `OK` (4 + 10 testes); `ok: lib_rele` e `ok: acs_gps_e2e`; `flutter analyze` sem problemas e `flutter test` ≥ 208; `ci_invariants` `ok: 9 grupos`; links sem erro; os dois GPS `OK`; jornada `OK`; `sinalacs-serverpod … (healthy)` e `ls .e2e/fixtures.json` → inexistente. + +- [ ] **Step 3: Estado do repositório e commit** + +```bash +git status --short && git log --oneline -9 && git log -9 --format=%B | grep -ci "co-authored\|generated with" +git add PROGRESS.md apps/CLAUDE.md +git commit -m "docs: registra o fechamento dos minors da revisão do app ACS" +``` +Expected: árvore limpa depois do commit; `0` na contagem de atribuições de IA; sem push. + +--- + +## Self-review + +**Cobertura:** item 1 → Task 1; 2 → Task 6 (com medição e fallback explícito); 3 e 4 → Task 5; 5 → Task 4; 6 → Task 3; 7 → Task 2; documentação e barra → Task 7. + +**Placeholders:** não há "TBD". Dois pontos dependem de medir no emulador e têm as duas saídas escritas: os ids dos botões do diálogo (Task 6, Step 1) e se recusar o diálogo chega a `USER_FIXED` (Task 6, Step 6 → Step 8 de fallback). O Step 6 também descreve a "prova contrária" em prosa, não como comando. + +**Consistência de nomes:** `porta_ocupada` (Task 2) usado só nos 3 runners; `Handler.acs_entregue` (Task 3) só no relé; `instalado`, `limpar`, `instalamos`, `amostras`, `amostrador` definidos na Task 5/6 e usados no mesmo script; `EXPECT_PERMISSION` ∈ `granted|denied_forever|prime` coerente entre o `.sh`, o teste hermético (`grep "EXPECT_PERMISSION=denied_forever"`) e o Dart; exit codes 2/3/4/5/1 batem entre o script e o teste. + +**Review Focus:** as 5 linhas têm teste dono (Task 5 `guarda` e `drive_falha`; Task 2; Task 3; Task 1). + +**Riscos que só a execução resolve:** (1) o `uiautomator` achar e tocar o botão no Android 16 (Task 6 tem medição e fallback); (2) `ss -ltn "sport = :N"` em hosts sem IPv6 (o teste pula o endereço `::`); (3) o caso `nao_fixa` leva ~3 s por causa do tocador em loop. From 24c4a4bf5f377a2a816db72d58e7205fa7299362 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:38:35 -0400 Subject: [PATCH 11/43] docs: corrige a contagem do validation_report e adiciona conferidor --- scripts/qa/contagem_validation_report.py | 51 +++++++++++++++++++ scripts/qa/contagem_validation_report_test.py | 34 +++++++++++++ spec/validation_report.md | 2 +- 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100755 scripts/qa/contagem_validation_report.py create mode 100644 scripts/qa/contagem_validation_report_test.py diff --git a/scripts/qa/contagem_validation_report.py b/scripts/qa/contagem_validation_report.py new file mode 100755 index 00000000..fe5abb3c --- /dev/null +++ b/scripts/qa/contagem_validation_report.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Confere a linha "**Contagem:**" de spec/validation_report.md contra a tabela de RF. + +A linha foi escrita à mão e ficou para trás quando RF13 mudou de status; este +conferidor recalcula a partir das linhas `| RFnn | ... | **status** | ... |`. +Status fora da lista conhecida é erro (exit 2), não linha ignorada: ignorar +deixaria a contagem errada de novo, em silêncio. +""" +import pathlib +import re +import sys + +ORDEM = ["backend", "backend + app", "app-only", "parcial", "ausente"] +LINHA_RF = re.compile(r"^\| RF\d+ \|[^|]*\| \*\*([^*]+)\*\* \|", re.M) +LINHA_CONTAGEM = re.compile(r"^\*\*Contagem:\*\* (.+)\.$", re.M) + + +def contar(texto): + contagem = {chave: 0 for chave in ORDEM} + for status in LINHA_RF.findall(texto): + if status not in contagem: + raise ValueError(f"status desconhecido na tabela de RF: {status!r}") + contagem[status] += 1 + return contagem + + +def esperada(contagem): + return " · ".join(f"{contagem[chave]} `{chave}`" for chave in ORDEM if contagem[chave]) + + +def conferir(texto): + declarada = LINHA_CONTAGEM.search(texto) + if declarada is None: + raise ValueError("sem linha '**Contagem:**'") + return declarada.group(1), esperada(contar(texto)) + + +if __name__ == "__main__": + caminho = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "spec/validation_report.md") + try: + declarada_, esperada_ = conferir(caminho.read_text(encoding="utf-8")) + except ValueError as erro: + print(f"erro: {erro}", file=sys.stderr) + sys.exit(2) + if declarada_ != esperada_: + print( + f"erro: contagem desatualizada em {caminho}\n declarada: {declarada_}\n da tabela: {esperada_}", + file=sys.stderr, + ) + sys.exit(1) + print("ok: contagem do validation_report confere") diff --git a/scripts/qa/contagem_validation_report_test.py b/scripts/qa/contagem_validation_report_test.py new file mode 100644 index 00000000..7a61e52a --- /dev/null +++ b/scripts/qa/contagem_validation_report_test.py @@ -0,0 +1,34 @@ +import unittest + +from contagem_validation_report import conferir, contar + +TABELA = """\ +| RF01 | A | **backend** | x | +| RF02 | B | **parcial** | x | +| RF03 | C | **ausente** | x | +| RNF01 | D | **parcial** | x | + +**Contagem:** {linha}. +""" + + +class ContagemTest(unittest.TestCase): + def test_ignora_rnf_e_confere(self): + declarada, esperada = conferir(TABELA.format(linha="1 `backend` · 1 `parcial` · 1 `ausente`")) + self.assertEqual(declarada, esperada) + + def test_detecta_divergencia(self): + declarada, esperada = conferir(TABELA.format(linha="1 `backend` · 2 `parcial` · 1 `ausente`")) + self.assertNotEqual(declarada, esperada) + + def test_status_desconhecido_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF09 | Z | **quase** | x |\n") + + def test_sem_linha_de_contagem_e_erro(self): + with self.assertRaises(ValueError): + conferir("| RF01 | A | **backend** | x |\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/spec/validation_report.md b/spec/validation_report.md index b2f0688e..43883786 100644 --- a/spec/validation_report.md +++ b/spec/validation_report.md @@ -93,7 +93,7 @@ cliente · **`parcial`** = existe, mas alimentado por dado fabricado · | RF17 | Logs de auditoria e conformidade | **backend** | `audit_logs` encadeado por HMAC; gravou `granted` e `denied_territory` nesta validação; cadeia verificada íntegra. | | RF18 | Dark mode nativo | **app-only** | Tema único dark nos três apps, com matriz de contraste testada. | -**Contagem:** 7 `backend` · 2 `backend + app` · 1 `app-only` · 3 `parcial` · 5 `ausente`. +**Contagem:** 7 `backend` · 2 `backend + app` · 1 `app-only` · 4 `parcial` · 4 `ausente`. ### Requisitos não funcionais From 2e481d553fe28301e6c18d28ace6d9e642d54aee Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:39:03 -0400 Subject: [PATCH 12/43] =?UTF-8?q?fix(qa):=20checagem=20da=20porta=208765?= =?UTF-8?q?=20enxerga=20qualquer=20endere=C3=A7o,=20n=C3=A3o=20s=C3=B3=201?= =?UTF-8?q?27.0.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/qa/acs_full_e2e.sh | 3 ++- scripts/qa/lib_rele.sh | 10 +++++++++ scripts/qa/lib_rele_test.sh | 39 ++++++++++++++++++++++++++++++++++ scripts/qa/patient_full_e2e.sh | 3 ++- scripts/qa/push_e2e.sh | 3 ++- 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 scripts/qa/lib_rele.sh create mode 100755 scripts/qa/lib_rele_test.sh diff --git a/scripts/qa/acs_full_e2e.sh b/scripts/qa/acs_full_e2e.sh index 84d98470..56467ecb 100755 --- a/scripts/qa/acs_full_e2e.sh +++ b/scripts/qa/acs_full_e2e.sh @@ -10,6 +10,7 @@ # integration_test/full_journey_e2e.dart no emulador. Nada é escrito no banco # de desenvolvimento; o banco e o manifesto são apagados ao final. set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" @@ -28,7 +29,7 @@ cleanup() { } trap cleanup EXIT -if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then +if porta_ocupada 8765; then echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 exit 1 fi diff --git a/scripts/qa/lib_rele.sh b/scripts/qa/lib_rele.sh new file mode 100644 index 00000000..6e6fd342 --- /dev/null +++ b/scripts/qa/lib_rele.sh @@ -0,0 +1,10 @@ +# Funções compartilhadas pelos runners que sobem o relé do OTP (otp_relay.py). +# Uso: source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" + +# Sucesso (0) se ALGUM processo escuta na porta TCP $1, em qualquer endereço +# (127.0.0.1, 0.0.0.0, [::] ou o IP da rede). Um relé antigo esquecido num +# deles responderia com código velho; olhar só 127.0.0.1 deixava passar os +# outros e o erro só aparecia no bind do relé novo, sem dizer o motivo. +porta_ocupada() { + ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN +} diff --git a/scripts/qa/lib_rele_test.sh b/scripts/qa/lib_rele_test.sh new file mode 100755 index 00000000..adbbbe08 --- /dev/null +++ b/scripts/qa/lib_rele_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Teste hermético de scripts/qa/lib_rele.sh (porta_ocupada) e de que os três +# runners do relé a usam. Não precisa de emulador nem de Docker. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +# shellcheck disable=SC1091 +source scripts/qa/lib_rele.sh +falhas=0 +confere() { "$@" || { echo "FALHOU: $*"; falhas=$((falhas + 1)); }; } +nega() { if "$@"; then echo "FALHOU (esperava falso): $*"; falhas=$((falhas + 1)); fi; } + +porta="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')" +nega porta_ocupada "$porta" + +for endereco in 127.0.0.1 0.0.0.0 ::; do + python3 -c ' +import socket, sys, time +familia = socket.AF_INET6 if ":" in sys.argv[1] else socket.AF_INET +s = socket.socket(familia) +s.bind((sys.argv[1], int(sys.argv[2]))) +s.listen() +time.sleep(30)' "$endereco" "$porta" 2>/dev/null & + pid=$! + for _ in $(seq 1 20); do porta_ocupada "$porta" && break; sleep 0.1; done + if kill -0 "$pid" 2>/dev/null; then + confere porta_ocupada "$porta" + else + echo "pulado: este host não consegue escutar em $endereco" + fi + kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null + for _ in $(seq 1 20); do porta_ocupada "$porta" || break; sleep 0.1; done +done + +for f in acs_full_e2e.sh patient_full_e2e.sh push_e2e.sh; do + nega grep -qF "grep -q '127.0.0.1:8765 '" "scripts/qa/$f" + confere grep -q "porta_ocupada 8765" "scripts/qa/$f" +done + +[[ "$falhas" -eq 0 ]] && echo "ok: lib_rele" || { echo "$falhas falha(s)"; exit 1; } diff --git a/scripts/qa/patient_full_e2e.sh b/scripts/qa/patient_full_e2e.sh index 8cd93816..6650fa94 100755 --- a/scripts/qa/patient_full_e2e.sh +++ b/scripts/qa/patient_full_e2e.sh @@ -19,6 +19,7 @@ # Pré-requisitos: emulador `emulator-5554` (para o push: Google Play e internet), o `.env` # (./scripts/dev/bootstrap_env.sh) e, para o push, as credenciais descritas em push_e2e.sh. set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" @@ -38,7 +39,7 @@ adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado relay_pid="" # O relé precisa ser ESTE processo: um relé antigo esquecido na porta responderia com código velho. iniciar_rele() { - if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then + if porta_ocupada 8765; then echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 exit 1 fi diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index da3ddadc..1082b602 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -21,6 +21,7 @@ # # Nunca imprime a chave nem o token FCM. set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" # Controle de jobs: cada processo em segundo plano vira um grupo próprio, e `parar_arvore` mata o # grupo inteiro (o `flutter test` é NETO do subshell que o lança; matar só o subshell o deixava vivo). set -m @@ -90,7 +91,7 @@ adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado relay_pid="" # O relé precisa ser ESTE processo: um relé antigo esquecido na porta responderia com código velho. iniciar_rele() { - if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then + if porta_ocupada 8765; then echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 exit 1 fi From 763278940207116f85e49bc75ee4188aeea911b2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:39:35 -0400 Subject: [PATCH 13/43] =?UTF-8?q?fix(qa):=20rel=C3=A9=20serve=20a=20senha?= =?UTF-8?q?=20sint=C3=A9tica=20do=20ACS=20uma=20=C3=BAnica=20vez=20por=20e?= =?UTF-8?q?xecu=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/qa/otp_relay.py | 9 ++++++- scripts/qa/otp_relay_test.py | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/scripts/qa/otp_relay.py b/scripts/qa/otp_relay.py index 8f26e60c..a389a090 100755 --- a/scripts/qa/otp_relay.py +++ b/scripts/qa/otp_relay.py @@ -4,6 +4,11 @@ O gateway de log escreve o código no stdout do servidor; o emulador não lê `docker logs`. Este servidor expõe só o código mais recente, sem destino nem CPF (o log não os tem). Escuta em 127.0.0.1; o emulador chega por `adb reverse`. + +`/acs` (só com E2E_FIXTURES_FILE) entrega a matrícula e a senha SINTÉTICAS do ACS +da execução, **uma única vez por processo**: qualquer app do emulador que alcance +localhost:8765 (adb reverse) poderia lê-las, então depois do primeiro pedido bem +sucedido — o do teste, no começo — a rota responde 404. O relé é por execução. """ import json, os, re, subprocess, sys, time from http.server import BaseHTTPRequestHandler, HTTPServer @@ -44,6 +49,7 @@ def acs_do_manifesto(caminho): class Handler(BaseHTTPRequestHandler): porta = 8765 container = "sinalacs-serverpod" + acs_entregue = False def do_GET(self): if not host_permitido(self.headers.get("Host"), self.porta): @@ -56,8 +62,9 @@ def do_GET(self): return if url.path == "/acs": credencial = acs_do_manifesto(os.environ.get("E2E_FIXTURES_FILE")) - if credencial is None: + if credencial is None or Handler.acs_entregue: self.send_error(404); return + Handler.acs_entregue = True self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers() self.wfile.write(json.dumps(credencial).encode()) return diff --git a/scripts/qa/otp_relay_test.py b/scripts/qa/otp_relay_test.py index 1abb3604..9a32305f 100644 --- a/scripts/qa/otp_relay_test.py +++ b/scripts/qa/otp_relay_test.py @@ -43,5 +43,55 @@ def test_sem_arquivo_nao_serve_nada(self): self.assertIsNone(acs_do_manifesto(None)) self.assertIsNone(acs_do_manifesto("/nao/existe.json")) +class AcsEntregaUnicaTest(unittest.TestCase): + """Qualquer app do emulador alcança localhost:8765 (adb reverse): a senha + sintética do ACS só pode sair uma vez por execução do relé.""" + + def setUp(self): + import json + import os + import tempfile + import threading + from http.server import HTTPServer + + import otp_relay + + self.otp_relay = otp_relay + arquivo = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + json.dump({"acs": {"matricula": "E2E-1", "password": "s"}}, arquivo) + arquivo.close() + self.caminho = arquivo.name + os.environ["E2E_FIXTURES_FILE"] = self.caminho + otp_relay.Handler.acs_entregue = False + self.servidor = HTTPServer(("127.0.0.1", 0), otp_relay.Handler) + otp_relay.Handler.porta = self.servidor.server_address[1] + threading.Thread(target=self.servidor.serve_forever, daemon=True).start() + + def tearDown(self): + import os + + self.servidor.shutdown() + self.servidor.server_close() + os.environ.pop("E2E_FIXTURES_FILE", None) + os.unlink(self.caminho) + self.otp_relay.Handler.porta = 8765 + self.otp_relay.Handler.acs_entregue = False + + def _get(self): + import urllib.error + import urllib.request + + url = f"http://127.0.0.1:{self.servidor.server_address[1]}/acs" + try: + with urllib.request.urlopen(url) as resposta: + return resposta.status + except urllib.error.HTTPError as erro: + return erro.code + + def test_serve_uma_vez_e_depois_404(self): + self.assertEqual(self._get(), 200) + self.assertEqual(self._get(), 404) + + if __name__ == "__main__": unittest.main() From 7221beaba4da64b720dda789d1ab43a3e653897b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:41:17 -0400 Subject: [PATCH 14/43] =?UTF-8?q?test(acs):=20confere=20RNF06=20tamb=C3=A9?= =?UTF-8?q?m=20no=20servidor=20e=20exige=20seletor=20sem=20filtro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acs/integration_test/full_journey_e2e.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index d6e27f7a..673fe20e 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -70,6 +70,10 @@ void main() { await tester.tap(find.text('Visita')); await _pumpUntil(tester, () => find.byKey(const Key('patient_picker')).evaluate().isNotEmpty); expect(find.byKey(Key('patient_${main.id}')), findsOneWidget); + // O seletor não pode estar filtrado: sem isto, "o de fora não aparece" + // poderia ser só um filtro por nome escondendo tudo o que não casa. + final busca = tester.widget(find.byKey(const Key('patient_search'))); + expect(busca.controller!.text, isEmpty, reason: 'o seletor deve abrir sem filtro de busca'); expect(find.byKey(Key('patient_${outsider.id}')), findsNothing, reason: 'outra microárea'); await tester.tap(find.byKey(Key('patient_${main.id}'))); @@ -85,6 +89,12 @@ void main() { final acsClient = novoCliente(); addTearDown(acsClient.close); final acs = await acsClient.auth.loginInstitutional(matricula: cred.matricula, password: cred.senha); + + // RNF06 no servidor: a lista da microárea nunca traz o paciente de fora. + // Quem garante é o backend, não o filtro do seletor. + final daMicroarea = await acsClient.patients.listMicroArea(accessToken: acs.accessToken); + expect(daMicroarea.any((p) => p.patientId == main.id), isTrue); + expect(daMicroarea.any((p) => p.patientId == outsider.id), isFalse, reason: 'outra microárea'); final remotas = await acsClient.visits.pull( accessToken: acs.accessToken, since: DateTime.fromMillisecondsSinceEpoch(0), From 6f6bc03d7610d2769687aaa08eade6e4886ad2dc Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:42:50 -0400 Subject: [PATCH 15/43] fix(qa): script de GPS avisa antes de apagar o app, mostra o erro do build e limpa ao sair --- scripts/qa/acs_gps_e2e.sh | 48 +++++++++++++++++++---- scripts/qa/acs_gps_e2e_test.sh | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 7 deletions(-) create mode 100755 scripts/qa/acs_gps_e2e_test.sh diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh index a2554383..0c1eae0b 100755 --- a/scripts/qa/acs_gps_e2e.sh +++ b/scripts/qa/acs_gps_e2e.sh @@ -4,6 +4,7 @@ # # ./scripts/qa/acs_gps_e2e.sh # concedida: o app a enxerga (whileInUse) # ./scripts/qa/acs_gps_e2e.sh --sem-permissao # negada: "indisponível", sem travar +# ... --reinstalar # autoriza apagar um app já instalado # # O diálogo de permissão é do sistema e o integration_test não o toca: o APK é # instalado já com a permissão concedida (ou revogada) por `pm` — e `pm grant` @@ -14,27 +15,60 @@ # `flutter test` reinstalaria o APK e perderia a concessão), e o APK é # construído com o próprio teste como alvo. # Não precisa da stack: o teste usa FakeAcsBackend. +# GUARDA: se o app do ACS já estiver instalado no emulador, o script aborta +# (exit 3) — reinstalar APAGA a fila de visitas offline (SQLCipher) e a chave do +# Keystore do app de desenvolvimento. `--reinstalar` autoriza. O script é dono +# do pacote enquanto roda e o remove ao sair, para a próxima rodada não esbarrar +# na própria guarda. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" -[[ -f .env ]] || { echo 'erro: rode ./scripts/dev/bootstrap_env.sh'; exit 1; } -set -a; source .env; set +a # MQTT_ACS_PASSWORD: o build do APK a exige +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" cd apps/acs dev=emulator-5554 pkg=br.com.prismrr.sinalacs.acs apk=build/app/outputs/flutter-apk/app-debug.apk expect=granted -[[ "${1:-}" == --sem-permissao ]] && expect=denied +reinstalar=0 +for arg in "$@"; do + case "$arg" in + --sem-permissao) expect=denied ;; + --reinstalar) reinstalar=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } -flutter build apk --debug --target=integration_test/geofence_gps_e2e.dart \ - --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ - --dart-define=EXPECT_PERMISSION="$expect" >/dev/null -adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true +instalado() { adb -s "$dev" shell pm list packages "$pkg" 2>/dev/null | grep -q "^package:$pkg\$"; } +if instalado && [[ "$reinstalar" -eq 0 ]]; then + echo "erro: $pkg já está instalado em $dev." >&2 + echo " Reinstalar APAGA a fila de visitas offline (SQLCipher) e a chave do Keystore deste aparelho." >&2 + echo " Se for só o app de teste de uma execução anterior, rode de novo com --reinstalar." >&2 + exit 3 +fi + +log="$(mktemp)" +instalamos=0 +limpar() { + rm -f "$log" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT + +if ! flutter build apk --debug --target=integration_test/geofence_gps_e2e.dart \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=EXPECT_PERMISSION="$expect" >"$log" 2>&1; then + echo "erro: o build do APK falhou. Últimas linhas:" >&2 + tail -n 30 "$log" >&2 + exit 1 +fi +if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi adb -s "$dev" install -r "$apk" >/dev/null +instalamos=1 for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do if [[ "$expect" == granted ]]; then adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" diff --git a/scripts/qa/acs_gps_e2e_test.sh b/scripts/qa/acs_gps_e2e_test.sh new file mode 100755 index 00000000..5cf98f0c --- /dev/null +++ b/scripts/qa/acs_gps_e2e_test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Teste hermético de scripts/qa/acs_gps_e2e.sh: adb e flutter FALSOS no PATH. +# Não precisa de emulador, de Docker nem do .env (a senha vai pelo ambiente). +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +mkdir "$tmp/bin" +export FAKE_LOG="$tmp/chamadas.log" + +cat >"$tmp/bin/adb" <<'FIM' +#!/usr/bin/env bash +echo "adb $*" >>"$FAKE_LOG" +case "$*" in + *get-state*) echo device ;; + *"pm list packages"*) [[ "${FAKE_INSTALADO:-0}" == 1 ]] && echo "package:br.com.prismrr.sinalacs.acs" ;; + *"dumpsys window"*) [[ "${FAKE_DIALOGO:-0}" == 1 ]] && echo "mCurrentFocus=Window{1 u0 com.google.android.permissioncontroller/x.GrantPermissionsActivity}" ;; + *"dumpsys package"*) [[ "${FAKE_FIXADA:-0}" == 1 ]] && echo "android.permission.ACCESS_FINE_LOCATION: granted=false, flags=[ USER_SET|USER_FIXED ]" ;; +esac +exit 0 +FIM +cat >"$tmp/bin/flutter" <<'FIM' +#!/usr/bin/env bash +echo "flutter $*" >>"$FAKE_LOG" +case "$1" in + build) [[ "${FAKE_BUILD_FALHA:-0}" == 1 ]] && { echo "ERRO-FALSO-DO-GRADLE: sem espaço" >&2; exit 1; } ;; + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 ;; +esac +exit 0 +FIM +chmod +x "$tmp/bin/"* + +falhas=0 +caso() { # caso [VAR=valor ...] -- [args do script] + nome=$1; shift + envs=(); while [[ $# -gt 0 && $1 != -- ]]; do envs+=("$1"); shift; done; shift + : >"$FAKE_LOG" + saida="$(env PATH="$tmp/bin:$PATH" MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? +} +verifica() { eval "$1" || { echo "FALHOU [$nome]: $1"; echo "$saida" | tail -4; falhas=$((falhas + 1)); }; } +conta() { grep -c "$1" "$FAKE_LOG" || true; } + +caso guarda FAKE_INSTALADO=1 -- +verifica '[[ $codigo -eq 3 ]]' +verifica 'grep -q "APAGA" <<<"$saida"' +verifica '[[ $(conta uninstall) -eq 0 ]]' +verifica '[[ $(conta "^flutter build") -eq 0 ]]' + +caso build_falha FAKE_BUILD_FALHA=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica 'grep -q "o build do APK falhou" <<<"$saida"' +verifica 'grep -q "ERRO-FALSO-DO-GRADLE" <<<"$saida"' +verifica '[[ $(conta "install -r") -eq 0 ]]' + +caso reinstalar FAKE_INSTALADO=1 -- --reinstalar +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta uninstall) -eq 2 ]]' # antes de instalar e ao sair + +caso drive_falha FAKE_DRIVE_FALHA=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica '[[ $(conta uninstall) -eq 1 ]]' # o app de teste não fica para a próxima rodada + +caso ok -- +verifica '[[ $codigo -eq 0 ]]' +verifica 'grep -q "fix de GPS não verificado" <<<"$saida"' + +caso argumento_invalido -- --nao-existe +verifica '[[ $codigo -eq 2 ]]' + +[[ "$falhas" -eq 0 ]] && echo "ok: acs_gps_e2e" || { echo "$falhas falha(s)"; exit 1; } From f53f90031e894b3454a323ec0cab7797e7d12706 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 20:57:24 -0400 Subject: [PATCH 16/43] =?UTF-8?q?test(acs):=20permiss=C3=A3o=20negada=20de?= =?UTF-8?q?=20vez=20e=20checagem=20de=20di=C3=A1logo=20do=20sistema=20no?= =?UTF-8?q?=20e2e=20de=20GPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration_test/geofence_gps_e2e.dart | 15 +++- scripts/qa/acs_gps_e2e.sh | 69 ++++++++++++++++--- scripts/qa/acs_gps_e2e_test.sh | 11 ++- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/apps/acs/integration_test/geofence_gps_e2e.dart b/apps/acs/integration_test/geofence_gps_e2e.dart index c4d46eb3..281e37de 100644 --- a/apps/acs/integration_test/geofence_gps_e2e.dart +++ b/apps/acs/integration_test/geofence_gps_e2e.dart @@ -1,6 +1,6 @@ /// RF12 no aparelho: a PERMISSÃO de localização em runtime e o que a tela faz /// com ela. Cenário e pré-requisitos em `scripts/qa/acs_gps_e2e.sh` (concede ou -/// revoga a permissão por `pm` antes de rodar). `--dart-define=EXPECT_PERMISSION=granted|denied`. +/// revoga a permissão por `pm` antes de rodar). `--dart-define=EXPECT_PERMISSION=granted|denied_forever`. /// /// O que isto prova: `pm grant` só funciona se o manifesto DECLARA a permissão /// (o defeito que o `android_manifest_test.dart` guarda), e o app, com ela, @@ -36,7 +36,16 @@ void main() { expect(permission, anyOf(LocationPermission.whileInUse, LocationPermission.always), reason: 'o manifesto precisa declarar ACCESS_FINE_LOCATION para a permissão existir'); } else { - expect(permission, LocationPermission.denied); + // Leva a permissão a negada-de-vez NA MESMA SESSÃO (o `flutter drive` desinstala o app ao + // terminar, então o estado não sobrevive entre rodadas). Enquanto não está fixada, cada + // `requestPermission()` abre o diálogo do sistema, que o script do emulador recusa; a + // segunda recusa a torna permanente e o pedido passa a voltar na hora, sem diálogo. + expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + var resposta = await Geolocator.requestPermission(); + for (var i = 0; i < 2 && resposta != LocationPermission.deniedForever; i++) { + resposta = await Geolocator.requestPermission(); + } + expect(resposta, LocationPermission.deniedForever); } await tester.pumpWidget(SinalAcsApp( @@ -58,7 +67,7 @@ void main() { await tester.tap(find.text('Geofencing')); await _pumpFor(tester, const Duration(seconds: 3)); - if (_expect == 'denied') { + if (_expect == 'denied_forever') { // Review Focus 1: permissão negada nunca trava o ACS. expect(find.textContaining('Localização atual indisponível'), findsOneWidget); expect(find.byKey(const Key('geofence_visit')), findsOneWidget); diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh index 0c1eae0b..57d2e6b1 100755 --- a/scripts/qa/acs_gps_e2e.sh +++ b/scripts/qa/acs_gps_e2e.sh @@ -3,7 +3,7 @@ # Permissão de localização do ACS (RF12) no emulador-5554. # # ./scripts/qa/acs_gps_e2e.sh # concedida: o app a enxerga (whileInUse) -# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # negada: "indisponível", sem travar +# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # negada DE VEZ (USER_FIXED): "indisponível", sem travar # ... --reinstalar # autoriza apagar um app já instalado # # O diálogo de permissão é do sistema e o integration_test não o toca: o APK é @@ -36,7 +36,7 @@ expect=granted reinstalar=0 for arg in "$@"; do case "$arg" in - --sem-permissao) expect=denied ;; + --sem-permissao) expect=denied_forever ;; --reinstalar) reinstalar=1 ;; *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; esac @@ -52,9 +52,13 @@ if instalado && [[ "$reinstalar" -eq 0 ]]; then fi log="$(mktemp)" +amostras="" +toques="" +amostrador="" instalamos=0 limpar() { - rm -f "$log" + rm -f "$log" "$amostras" "$toques" + if [[ -n "$amostrador" ]]; then kill "$amostrador" 2>/dev/null || true; fi if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi } trap limpar EXIT @@ -69,16 +73,63 @@ fi if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi adb -s "$dev" install -r "$apk" >/dev/null instalamos=1 -for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do - if [[ "$expect" == granted ]]; then +# Toca em "Não permitir" no diálogo de permissão do sistema, se ele estiver na tela. +tocar_recusar() { + local xml xy + xml="$(adb -s "$dev" exec-out uiautomator dump /dev/tty 2>/dev/null || true)" + xy="$(python3 - "$xml" <<'PY' +import re, sys +xml = sys.argv[1] +for rid in ("permission_deny_and_dont_ask_again_button", "permission_deny_button"): + m = re.search(r'resource-id="com\.android\.permissioncontroller:id/%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % rid, xml) + if m: + x1, y1, x2, y2 = map(int, m.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + break +PY +)" + if [[ -n "$xy" ]]; then echo tap >>"$toques"; adb -s "$dev" shell input tap $xy; fi + return 0 +} + +if [[ "$expect" == granted ]]; then + for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" - else + done +else + for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true - fi -done + done +fi + +# O teste de widget do Flutter injeta toques no próprio Flutter e NÃO percebe um diálogo +# do sistema aberto por cima do app. Quem percebe é o foco da janela, amostrado durante a execução. +# No cenário `denied_forever` o diálogo é ESPERADO (é o que o teste recusa para fixar a negação), +# então lá o que se confere é o número de toques do tocador: no máximo 2 (as duas recusas). +amostras="$(mktemp)" +toques="$(mktemp)" +amostrar() { adb -s "$dev" shell dumpsys window 2>/dev/null | grep -m1 mCurrentFocus >>"$amostras" || true; } +if [[ "$expect" == granted ]]; then + ( while true; do amostrar; sleep 1; done ) & +else + ( while true; do tocar_recusar; sleep 1; done ) & +fi +amostrador=$! +drive_rc=0 flutter drive --driver=test_driver/integration_test.dart \ --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ --use-application-binary="$apk" \ - --dart-define=EXPECT_PERMISSION="$expect" + --dart-define=EXPECT_PERMISSION="$expect" || drive_rc=$? +if [[ "$expect" == granted ]]; then amostrar; fi # uma leitura final garante ao menos uma amostra +kill "$amostrador" 2>/dev/null || true; amostrador="" +if grep -q permissioncontroller "$amostras"; then + echo "erro: o diálogo de permissão do sistema apareceu por cima do app durante o teste." >&2 + exit 1 +fi +if [[ "$(wc -l <"$toques")" -gt 2 ]]; then + echo "erro: o diálogo de permissão apareceu mais de 2 vezes: a permissão não ficou negada de vez." >&2 + exit 1 +fi +if [[ "$drive_rc" -ne 0 ]]; then exit "$drive_rc"; fi echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" diff --git a/scripts/qa/acs_gps_e2e_test.sh b/scripts/qa/acs_gps_e2e_test.sh index 5cf98f0c..9aaac04d 100755 --- a/scripts/qa/acs_gps_e2e_test.sh +++ b/scripts/qa/acs_gps_e2e_test.sh @@ -14,7 +14,6 @@ case "$*" in *get-state*) echo device ;; *"pm list packages"*) [[ "${FAKE_INSTALADO:-0}" == 1 ]] && echo "package:br.com.prismrr.sinalacs.acs" ;; *"dumpsys window"*) [[ "${FAKE_DIALOGO:-0}" == 1 ]] && echo "mCurrentFocus=Window{1 u0 com.google.android.permissioncontroller/x.GrantPermissionsActivity}" ;; - *"dumpsys package"*) [[ "${FAKE_FIXADA:-0}" == 1 ]] && echo "android.permission.ACCESS_FINE_LOCATION: granted=false, flags=[ USER_SET|USER_FIXED ]" ;; esac exit 0 FIM @@ -66,4 +65,14 @@ verifica 'grep -q "fix de GPS não verificado" <<<"$saida"' caso argumento_invalido -- --nao-existe verifica '[[ $codigo -eq 2 ]]' +caso dialogo FAKE_DIALOGO=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica 'grep -q "diálogo de permissão" <<<"$saida"' + +caso sem_permissao -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "pm revoke") -eq 2 ]]' +verifica 'grep -q "EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' +verifica '[[ $(conta "^flutter drive") -eq 1 ]]' # uma rodada só: o teste fixa a negação sozinho + [[ "$falhas" -eq 0 ]] && echo "ok: acs_gps_e2e" || { echo "$falhas falha(s)"; exit 1; } From aaec3af0bdceaf3776590f59ad1acb9598e84b97 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 21:00:14 -0400 Subject: [PATCH 17/43] =?UTF-8?q?docs:=20registra=20o=20fechamento=20dos?= =?UTF-8?q?=20minors=20da=20revis=C3=A3o=20do=20app=20ACS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 1 + apps/CLAUDE.md | 2 +- .../plans/2026-10-01-minors-da-revisao-do-app-acs.md | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/PROGRESS.md b/PROGRESS.md index 889bbd1e..a8daba1e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -566,6 +566,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **RF13 — SAMU.** O botão abre o discador com 192 (`core/services/emergency_dialer.dart`, `url_launcher`, `` para `tel:`), sem ligar sozinho e sem `CALL_PHONE`; sem discador mostra o número em texto; trava de toque duplo. "Encaminhar para UBS Central" segue sendo um aviso (sem fonte de contato da UBS: decisão de produto). - **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. - **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). +- **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). - **Fora:** RF08 (cache persistido da microárea offline — exige desenho de LGPD antes), MFA/refresh token, iOS (`apps/acs/ios/` não existe), botão da UBS. - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index af2c2788..228fa64b 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -46,7 +46,7 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. -**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh` prova a permissão em runtime (não a chegada de um fix: o AVD não entrega posição) e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. diff --git a/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md b/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md index e431aec2..5f8c902f 100644 --- a/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md +++ b/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md @@ -10,6 +10,15 @@ **Spec:** `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md` (seção "Desvios da execução" e Tasks 3–4, que criaram o que aqui se corrige). A revisão que originou os 7 itens não está em disco; a tabela abaixo a resume. +> **Desvio da execução (Task 6) — leia antes dela.** O desenho de "priming em rodadas +> separadas" (`negar_de_vez`, modo `prime`, exit 5, checagem de `USER_FIXED` por `dumpsys`) +> **não funciona**: `flutter drive` desinstala o app ao terminar (medido), então o estado de +> permissão não sobrevive entre rodadas, e o `--dart-define` passado ao `drive` é ignorado com +> `--use-application-binary`. O que foi entregue: **uma rodada só**; o teste `denied_forever` +> chama `requestPermission()` até 2 vezes na mesma sessão enquanto um tocador (`uiautomator` + +> `input tap`) recusa cada diálogo; o script limita os toques a 2. O amostrador de foco da janela +> fica só no cenário `granted`. O id do botão medido foi `permission_deny_button`. + ## Os 7 itens e onde são fechados | # | Achado da revisão | Task | From 56272d9668c5d445b501e0fdd065e9817d895535 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 21:09:37 -0400 Subject: [PATCH 18/43] =?UTF-8?q?fix(qa):=20push=5Fe2e.sh=20sobe=20o=20rel?= =?UTF-8?q?=C3=A9=20de=20verdade;=20tocador=20do=20GPS=20=C3=A0=20prova=20?= =?UTF-8?q?de=20falha=20e=20drive=20com=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/qa/acha_botao_recusar.py | 29 +++++++++++++++++++++++++++ scripts/qa/acha_botao_recusar_test.py | 24 ++++++++++++++++++++++ scripts/qa/acs_gps_e2e.sh | 26 ++++++++++++------------ scripts/qa/acs_gps_e2e_test.sh | 7 ++++++- scripts/qa/lib_rele_test.sh | 11 ++++++++++ scripts/qa/push_e2e.sh | 6 +++--- 6 files changed, 86 insertions(+), 17 deletions(-) create mode 100755 scripts/qa/acha_botao_recusar.py create mode 100644 scripts/qa/acha_botao_recusar_test.py diff --git a/scripts/qa/acha_botao_recusar.py b/scripts/qa/acha_botao_recusar.py new file mode 100755 index 00000000..b0b7726b --- /dev/null +++ b/scripts/qa/acha_botao_recusar.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Lê o XML do `uiautomator dump` na ENTRADA PADRÃO e imprime "x y": o centro do botão +"Não permitir" do diálogo de permissão do sistema (nada, se ele não estiver na tela). + +Entrada por stdin, não por argv: um dump grande estoura o limite de um único argumento +(E2BIG), o que matava em silêncio o tocador de scripts/qa/acs_gps_e2e.sh. +""" +import re +import sys + +IDS = ("permission_deny_and_dont_ask_again_button", "permission_deny_button") + + +def achar(xml): + for rid in IDS: + m = re.search( + r'resource-id="com\.android\.permissioncontroller:id/%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % rid, + xml, + ) + if m: + x1, y1, x2, y2 = map(int, m.groups()) + return (x1 + x2) // 2, (y1 + y2) // 2 + return None + + +if __name__ == "__main__": + ponto = achar(sys.stdin.read()) + if ponto: + print(*ponto) diff --git a/scripts/qa/acha_botao_recusar_test.py b/scripts/qa/acha_botao_recusar_test.py new file mode 100644 index 00000000..5e73bc46 --- /dev/null +++ b/scripts/qa/acha_botao_recusar_test.py @@ -0,0 +1,24 @@ +import unittest + +from acha_botao_recusar import achar + +NO = '' + + +class AchaBotaoTest(unittest.TestCase): + def test_acha_o_centro(self): + self.assertEqual(achar(NO % "permission_deny_button"), (20, 40)) + + def test_prefere_o_nao_perguntar_de_novo(self): + grande = NO.replace("[10,20][30,60]", "[100,200][300,600]") % "permission_deny_and_dont_ask_again_button" + self.assertEqual(achar(NO % "permission_deny_button" + grande), (200, 400)) + + def test_sem_dialogo(self): + self.assertIsNone(achar('')) + + def test_dump_enorme(self): + self.assertEqual(achar("" * 100000 + NO % "permission_deny_button"), (20, 40)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh index 57d2e6b1..cd01be5d 100755 --- a/scripts/qa/acs_gps_e2e.sh +++ b/scripts/qa/acs_gps_e2e.sh @@ -77,18 +77,14 @@ instalamos=1 tocar_recusar() { local xml xy xml="$(adb -s "$dev" exec-out uiautomator dump /dev/tty 2>/dev/null || true)" - xy="$(python3 - "$xml" <<'PY' -import re, sys -xml = sys.argv[1] -for rid in ("permission_deny_and_dont_ask_again_button", "permission_deny_button"): - m = re.search(r'resource-id="com\.android\.permissioncontroller:id/%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % rid, xml) - if m: - x1, y1, x2, y2 = map(int, m.groups()) - print((x1 + x2) // 2, (y1 + y2) // 2) - break -PY -)" - if [[ -n "$xy" ]]; then echo tap >>"$toques"; adb -s "$dev" shell input tap $xy; fi + # O XML vai por stdin (um dump grande estoura o argv) e toda falha aqui é engolida: o tocador + # roda num subshell com `set -e`, e se ele morrer em silêncio o diálogo fica sem ninguém e o + # teste espera para sempre. + xy="$(printf '%s' "$xml" | python3 "$repo_root/scripts/qa/acha_botao_recusar.py" 2>/dev/null || true)" + if [[ -n "$xy" ]]; then + echo tap >>"$toques" + adb -s "$dev" shell input tap $xy || true + fi return 0 } @@ -117,7 +113,8 @@ fi amostrador=$! drive_rc=0 -flutter drive --driver=test_driver/integration_test.dart \ +limite="${GPS_E2E_TIMEOUT:-300}" +timeout "$limite" flutter drive --driver=test_driver/integration_test.dart \ --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ --use-application-binary="$apk" \ --dart-define=EXPECT_PERMISSION="$expect" || drive_rc=$? @@ -131,5 +128,8 @@ if [[ "$(wc -l <"$toques")" -gt 2 ]]; then echo "erro: o diálogo de permissão apareceu mais de 2 vezes: a permissão não ficou negada de vez." >&2 exit 1 fi +if [[ "$drive_rc" -eq 124 ]]; then + echo "erro: o flutter drive excedeu ${limite}s (um diálogo de permissão sem ninguém para recusá-lo?)." >&2 +fi if [[ "$drive_rc" -ne 0 ]]; then exit "$drive_rc"; fi echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" diff --git a/scripts/qa/acs_gps_e2e_test.sh b/scripts/qa/acs_gps_e2e_test.sh index 9aaac04d..fef6892b 100755 --- a/scripts/qa/acs_gps_e2e_test.sh +++ b/scripts/qa/acs_gps_e2e_test.sh @@ -22,7 +22,7 @@ cat >"$tmp/bin/flutter" <<'FIM' echo "flutter $*" >>"$FAKE_LOG" case "$1" in build) [[ "${FAKE_BUILD_FALHA:-0}" == 1 ]] && { echo "ERRO-FALSO-DO-GRADLE: sem espaço" >&2; exit 1; } ;; - drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 ;; + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1; [[ "${FAKE_DRIVE_TRAVA:-0}" == 1 ]] && sleep 30 ;; esac exit 0 FIM @@ -75,4 +75,9 @@ verifica '[[ $(conta "pm revoke") -eq 2 ]]' verifica 'grep -q "EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' verifica '[[ $(conta "^flutter drive") -eq 1 ]]' # uma rodada só: o teste fixa a negação sozinho +caso drive_trava FAKE_DRIVE_TRAVA=1 GPS_E2E_TIMEOUT=1 -- +verifica '[[ $codigo -eq 124 ]]' +verifica 'grep -q "excedeu" <<<"$saida"' +verifica '[[ $(conta uninstall) -eq 1 ]]' # e o app de teste não fica instalado + [[ "$falhas" -eq 0 ]] && echo "ok: acs_gps_e2e" || { echo "$falhas falha(s)"; exit 1; } diff --git a/scripts/qa/lib_rele_test.sh b/scripts/qa/lib_rele_test.sh index adbbbe08..ba55c1d1 100755 --- a/scripts/qa/lib_rele_test.sh +++ b/scripts/qa/lib_rele_test.sh @@ -36,4 +36,15 @@ for f in acs_full_e2e.sh patient_full_e2e.sh push_e2e.sh; do confere grep -q "porta_ocupada 8765" "scripts/qa/$f" done +# `iniciar_rele` (onde existe) não pode chamar a si mesma e precisa ser chamada de fora: +# no push_e2e.sh ela era recursiva e nunca rodava, então a checagem de porta nunca valia. +for f in push_e2e.sh patient_full_e2e.sh; do + arq="scripts/qa/$f" + corpo="$(awk '/^[[:space:]]*iniciar_rele\(\) \{/{d=1;next} d&&/^[[:space:]]*\}/{d=0} d' "$arq")" + chamada='^[[:space:]]*iniciar_rele[[:space:]]*$' + nega grep -qE "$chamada" <<<"$corpo" + total="$(grep -cE "$chamada" "$arq" || true)"; dentro="$(grep -cE "$chamada" <<<"$corpo" || true)" + confere test $((total - dentro)) -ge 1 +done + [[ "$falhas" -eq 0 ]] && echo "ok: lib_rele" || { echo "$falhas falha(s)"; exit 1; } diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 1082b602..acdabf8d 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -95,7 +95,8 @@ iniciar_rele() { echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 exit 1 fi - iniciar_rele + python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & + relay_pid=$! for _ in $(seq 1 20); do curl -fs http://127.0.0.1:8765/now >/dev/null 2>&1 && kill -0 "$relay_pid" 2>/dev/null && return 0 sleep 0.25 @@ -107,8 +108,7 @@ if [[ "$e2e_db" -eq 1 ]]; then echo "== stack de e2e (banco de teste) com o Gorush" ./scripts/qa/e2e_stack.sh up ./scripts/qa/e2e_stack.sh seed - python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & - relay_pid=$! + iniciar_rele export ACS_MATRICULA ACS_PASSWORD E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" ACS_MATRICULA="$(python3 -c "import json;print(json.load(open('.e2e/fixtures.json'))['acs']['matricula'])")" ACS_PASSWORD="$(python3 -c "import json;print(json.load(open('.e2e/fixtures.json'))['acs']['password'])")" From 253c6da72398674bd9ff4f13f91e34bb65410d75 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 09:26:26 -0400 Subject: [PATCH 19/43] fix(qa): conferidor de contagem recusa linha de RF sem status em negrito --- ...amento-gps-e2e-e-conferidor-de-contagem.md | 295 ++++++++++++++++++ scripts/qa/contagem_validation_report.py | 10 +- scripts/qa/contagem_validation_report_test.py | 8 + 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md diff --git a/docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md b/docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md new file mode 100644 index 00000000..e19c51bc --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md @@ -0,0 +1,295 @@ +# Fechamento do e2e de GPS do ACS e do conferidor de contagem — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Goal:** Fechar os 7 achados da revisão de `scripts/qa/acs_gps_e2e.sh`, `integration_test/geofence_gps_e2e.dart` e `scripts/qa/contagem_validation_report.py`. + +**Architecture:** A prova de "permissão fixada" passa a ser um estado lido do próprio Geolocator (`checkPermission()` depois de abrir o painel); a contagem de toques do tocador fica como trava complementar, agora exercitada pelo teste hermético (adb falso que mostra o diálogo). O script ganha ordem de limpeza correta, `dart-define` só onde tem efeito e comentários que dizem só o que é conferido. O conferidor de contagem passa a recusar linha de RF cujo status não esteja em negrito. + +**Tech Stack:** bash, Python 3 (`unittest`), Flutter `integration_test`, `geolocator`, adb. + +**Spec:** achados do pedido do usuário (7 itens, 2026-10-02); contexto em `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. + +## Global Constraints + +- Português em comentários, mensagens e nomes, como o resto de `scripts/qa/`. +- Commits sem atribuição de IA, sem "Co-Authored-By", sem "Generated with Claude Code" (regra do `CLAUDE.md` do projeto). Ficam na branch `fix/app_acs`, sem push. +- Nada de dado real de paciente em teste/log. +- O script nunca apaga um app já instalado sem `--reinstalar` (exit 3) — não mexer nessa guarda. +- Testes herméticos não precisam de emulador, Docker nem `.env`. + +## Review Focus + +- Script interrompido (SIGTERM/Ctrl-C) com o tocador vivo: arquivos temporários não podem sobrar. → Task 2, caso `interrompido`. +- Mais de 2 toques do tocador deve reprovar a rodada e exatamente 2 deve passar. → Task 2, casos `toques_demais` e `toques_ok`. +- Linha de RF com status sem negrito (`| RF05 | X | parcial | ... |`) não pode ser contada como "nada". → Task 1. +- `--sem-permissao`: o `EXPECT_PERMISSION` só vale no build; o drive não pode repeti-lo. → Task 2, caso `sem_permissao`. + +## Mapa de arquivos + +- Modificar `scripts/qa/contagem_validation_report.py` + `contagem_validation_report_test.py` (item 7). +- Modificar `scripts/qa/acs_gps_e2e.sh` (itens 3, 4, 5) e `scripts/qa/acs_gps_e2e_test.sh` (itens 4, 5, 6). +- Modificar `apps/acs/integration_test/geofence_gps_e2e.dart` (itens 1, 2, 3). + +--- + +### Task 1: Conferidor de contagem recusa status sem negrito (item 7) + +**Files:** +- Modify: `scripts/qa/contagem_validation_report.py` +- Test: `scripts/qa/contagem_validation_report_test.py` + +**Interfaces:** +- Consumes: — +- Produces: `contar(texto) -> dict` passa a levantar `ValueError` também para linha `| RFnn |` sem status em negrito. `conferir`/CLI inalterados (o erro vira exit 2). + +- [ ] **Step 1: Teste que falha** — acrescentar em `ContagemTest`: + +```python + def test_status_sem_negrito_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF05 | X | parcial | x |\n") + + def test_linha_de_rf_malformada_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF05 | X |\n") +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd scripts/qa && python3 -m unittest contagem_validation_report_test -v` +Expected: os 2 testes novos FALHAM (`AssertionError: ValueError not raised`), os 4 antigos passam. + +- [ ] **Step 3: Implementar** — em `contagem_validation_report.py`, trocar `contar` e adicionar a regex de qualquer linha de RF: + +```python +LINHA_QUALQUER_RF = re.compile(r"^\| RF\d+ \|.*$", re.M) +``` + +```python +def contar(texto): + contagem = {chave: 0 for chave in ORDEM} + for linha in LINHA_QUALQUER_RF.findall(texto): + achado = LINHA_RF.match(linha) + if achado is None: + raise ValueError(f"linha de RF sem status em negrito: {linha[:60]!r}") + status = achado.group(1) + if status not in contagem: + raise ValueError(f"status desconhecido na tabela de RF: {status!r}") + contagem[status] += 1 + return contagem +``` + +Atualizar a docstring: "linha de RF sem status em negrito também é erro (exit 2)". (`LINHA_RF` com `re.M` e `^` continua válida em `.match` sobre a linha.) + +- [ ] **Step 4: Ver passar** — mesmo comando. Expected: 6 testes OK. Depois: `python3 scripts/qa/contagem_validation_report.py` → `ok: contagem do validation_report confere` (a tabela real tem todas em negrito; se falhar, a falha é um achado real: corrigir a linha em `spec/validation_report.md`). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/contagem_validation_report.py scripts/qa/contagem_validation_report_test.py +git commit -m "fix(qa): conferidor de contagem recusa linha de RF sem status em negrito" +``` + +--- + +### Task 2: Script de GPS — limpeza, dart-define e toques, com testes herméticos (itens 4, 5, 6) + +**Files:** +- Modify: `scripts/qa/acs_gps_e2e_test.sh` +- Modify: `scripts/qa/acs_gps_e2e.sh` + +**Interfaces:** +- Produces: variável de ambiente `GPS_E2E_INTERVALO` (segundos entre amostras/toques, padrão `1`); constante `max_toques=2` no script. + +- [ ] **Step 1: Estender o teste hermético (falha primeiro)** + +(a) No `adb` falso, acrescentar antes do `esac`: + +```bash + *uiautomator*) + if [[ "${FAKE_DIALOGO_TOQUES:-0}" == 1 ]]; then + n="$(cat "$FAKE_LOG.dumps" 2>/dev/null || echo 0)" + if [[ -z "${FAKE_DIALOGO_LIMITE:-}" || "$n" -lt "$FAKE_DIALOGO_LIMITE" ]]; then + echo $((n + 1)) >"$FAKE_LOG.dumps" + echo '' + fi + fi ;; +``` + +(b) No `flutter` falso, trocar a linha do `drive` por: + +```bash + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 + [[ "${FAKE_DRIVE_TRAVA:-0}" == 1 ]] && sleep 30 + [[ -n "${FAKE_DRIVE_DEMORA:-}" ]] && sleep "$FAKE_DRIVE_DEMORA" ;; +``` + +(c) Em `caso()`, isolar o TMPDIR por caso e zerar o contador de dumps: + +```bash + rm -rf "$tmp/t" "$FAKE_LOG.dumps"; mkdir "$tmp/t" + saida="$(env PATH="$tmp/bin:$PATH" TMPDIR="$tmp/t" GPS_E2E_INTERVALO=0.1 MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? +``` + +(d) Trocar o `verifica` do caso `sem_permissao` e acrescentar os novos casos (antes da linha final `[[ "$falhas" ...`): + +```bash +caso sem_permissao -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "pm revoke") -eq 2 ]]' +verifica 'grep -q "^flutter build .*EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' # o define vale só no build +verifica '! grep "^flutter drive" "$FAKE_LOG" | grep -q EXPECT_PERMISSION' # o drive não o repete +verifica '[[ $(conta "^flutter drive") -eq 1 ]]' # uma rodada só: o teste fixa a negação sozinho +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' # nenhum temporário sobra + +caso toques_ok FAKE_DIALOGO_TOQUES=1 FAKE_DIALOGO_LIMITE=2 FAKE_DRIVE_DEMORA=2 -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "input tap") -eq 2 ]]' # exatamente o teto: passa + +caso toques_demais FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=2 -- --sem-permissao +verifica '[[ $codigo -eq 1 ]]' +verifica '[[ $(conta "input tap") -gt 2 ]]' # o caminho de >2 foi exercitado de verdade +verifica 'grep -q "mais de 2 vezes" <<<"$saida"' +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' + +caso interrompido FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=5 -- --sem-permissao +``` + +(`interrompido` precisa de SIGTERM no meio; como `caso` é síncrono, escrever este bloco à mão em vez de `caso`:) + +```bash +nome=interrompido; : >"$FAKE_LOG"; rm -rf "$tmp/t" "$FAKE_LOG.dumps"; mkdir "$tmp/t" +env PATH="$tmp/bin:$PATH" TMPDIR="$tmp/t" GPS_E2E_INTERVALO=0.1 MQTT_ACS_PASSWORD=x \ + FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=5 ./scripts/qa/acs_gps_e2e.sh --sem-permissao >/dev/null 2>&1 & +pid=$!; sleep 2; kill -TERM "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; saida="" +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' # item 5: nem o arquivo de toques vaza +verifica '[[ $(conta uninstall) -ge 1 ]]' # e o app de teste é removido +``` + +(Remover a linha `caso interrompido ...` e deixar só o bloco manual.) + +- [ ] **Step 2: Ver falhar** + +Run: `bash scripts/qa/acs_gps_e2e_test.sh` +Expected: FALHAM `sem_permissao` (o drive hoje leva `EXPECT_PERMISSION`), `toques_demais` ou `interrompido` (vazamento/sem intervalo configurável; `GPS_E2E_INTERVALO` ainda não existe, então `toques_ok` fica com 0–1 toque). Anotar quais falharam. + +- [ ] **Step 3: Implementar no script** + +(a) Intervalo configurável e teto nomeado, junto de `limite=`/antes do amostrador: + +```bash +intervalo="${GPS_E2E_INTERVALO:-1}" +max_toques=2 # as duas recusas que fixam a negação; um 3º toque = a permissão não ficou fixada +``` +Trocar os dois `sleep 1` dos laços `( while true; ... )` por `sleep "$intervalo"`, e `-gt 2` por `-gt "$max_toques"` (mensagem: `"... mais de $max_toques vezes ..."` — o teste casa em "mais de 2 vezes", que continua verdadeiro). + +(b) `limpar()` — matar e esperar o tocador ANTES de apagar o arquivo de toques (o laço recria o arquivo com `>>`), e tratar sinais: + +```bash +limpar() { + if [[ -n "$amostrador" ]]; then + kill "$amostrador" 2>/dev/null || true + wait "$amostrador" 2>/dev/null || true + fi + rm -f "$log" "$amostras" "$toques" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +``` +(Os `trap` de INT/TERM garantem que o EXIT trap rode mesmo com o `flutter drive` em primeiro plano. Se o caso `interrompido` ainda falhar, o árbitro é o teste: ajustar até passar, sem enfraquecê-lo.) + +No fluxo normal, trocar `kill "$amostrador" 2>/dev/null || true; amostrador=""` por `kill "$amostrador" 2>/dev/null || true; wait "$amostrador" 2>/dev/null || true; amostrador=""` para que a contagem de toques seja lida depois da morte do tocador. + +(c) `dart-define` do drive: remover a linha `--dart-define=EXPECT_PERMISSION="$expect"` do `flutter drive` (com `--use-application-binary` o valor já está embutido no APK construído; repeti-lo no drive sugere que ele vale ali). Deixar o comentário no ponto do build: + +```bash +# O EXPECT_PERMISSION é fixado AQUI, no build: o `flutter drive` abaixo usa o APK pronto +# (--use-application-binary) e não recompila, então um dart-define no drive não teria efeito. +``` + +- [ ] **Step 4: Ver passar** + +Run: `bash scripts/qa/acs_gps_e2e_test.sh` (repetir 3x: toques dependem de tempo) +Expected: `ok: acs_gps_e2e` nas 3 rodadas. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/acs_gps_e2e.sh scripts/qa/acs_gps_e2e_test.sh +git commit -m "fix(qa): gps e2e limpa o tocador antes dos temporários, sem define morto no drive e com caso hermético de >2 toques" +``` + +--- + +### Task 3: Teste Dart prova o estado, não a contagem; comentários honestos (itens 1, 2, 3) + +**Files:** +- Modify: `apps/acs/integration_test/geofence_gps_e2e.dart` +- Modify: `scripts/qa/acs_gps_e2e.sh` (só comentários do cabeçalho e do bloco do tocador) + +**Interfaces:** +- Consumes: `Geolocator.checkPermission()` / `requestPermission()`; os cenários `granted|denied_forever` da Task 2. +- Produces: — + +- [ ] **Step 1: Escrever a mudança do teste** (não há como falhar sem emulador; a prova é o passo 3) + +Em `geofence_gps_e2e.dart`, trocar o bloco `else` por no máximo 2 pedidos (1ª recusa + 2ª, que fixa): + +```dart + expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + // No máximo 2 pedidos: o script do emulador recusa o 1º diálogo e o 2º, e a 2ª recusa + // faz o Android parar de perguntar. Um 3º pedido seria só um pedido que já volta na hora. + var resposta = await Geolocator.requestPermission(); + if (resposta != LocationPermission.deniedForever) { + resposta = await Geolocator.requestPermission(); + } + expect(resposta, LocationPermission.deniedForever); +``` + +E, dentro do `if (_expect == 'denied_forever')` depois de abrir o painel, ANTES dos `expect` de texto: + +```dart + // A prova de "nada pergunta de novo": com a negação fixada, o painel de Geofencing não + // pode ter reaberto o fluxo de permissão. O estado lido de volta continua deniedForever. + expect(await Geolocator.checkPermission(), LocationPermission.deniedForever, + reason: 'abrir o painel não pode alterar nem reabrir a permissão já fixada'); +``` + +Corrigir o comentário do arquivo: no cabeçalho, onde se lê "negada-de-vez"/"USER_FIXED" não deve haver afirmação sobre a flag do Android — dizer "negada de vez (o Geolocator reporta `deniedForever`)". + +- [ ] **Step 2: Corrigir os comentários do script** + +Em `acs_gps_e2e.sh`: na linha 6, trocar `negada DE VEZ (USER_FIXED)` por `negada DE VEZ (o Geolocator reporta deniedForever)`. No comentário do bloco do tocador, reescrever as 3 linhas para: + +```bash +# No cenário `denied_forever` o diálogo é ESPERADO: o tocador abaixo o recusa. A prova principal de +# que a negação ficou fixada é do próprio teste (checkPermission() == deniedForever depois de abrir +# o painel). A contagem de toques é só uma trava complementar: no máximo $max_toques (as duas +# recusas); um 3º toque indica que o diálogo continuou aparecendo. Ela NÃO confere a flag USER_FIXED. +``` + +- [ ] **Step 3: Verificar** + +Run: `cd apps/acs && dart analyze integration_test/geofence_gps_e2e.dart` → `No issues found!` +Run: `bash scripts/qa/acs_gps_e2e_test.sh` → `ok: acs_gps_e2e` +Run (se houver o emulador-5554 livre do app): `./scripts/qa/acs_gps_e2e.sh --sem-permissao` e `./scripts/qa/acs_gps_e2e.sh` +Expected: `OK — permissão de localização em runtime (denied_forever)` e `(granted)`. Se o `checkPermission()` não vier `deniedForever` no emulador, NÃO afrouxar o `expect`: investigar (é exatamente o falso positivo que o item 1 queria expor). Sem emulador, dizer isso explicitamente ao reportar — a verificação em aparelho fica pendente. + +- [ ] **Step 4: Commit** + +```bash +git add apps/acs/integration_test/geofence_gps_e2e.dart scripts/qa/acs_gps_e2e.sh +git commit -m "test(acs): gps e2e confere deniedForever após abrir o painel, no máximo 2 pedidos e comentários sem USER_FIXED" +``` + +--- + +## Auto-revisão + +- **Cobertura:** 1 → Task 3; 2 → Task 3 (loop vira ≤2 pedidos, igual ao comentário e ao `max_toques`); 3 → Task 3 (comentários dos dois arquivos); 4 → Task 2 (define só no build, greps ancorados `^flutter build`/`^flutter drive`); 5 → Task 2 (`wait` antes do `rm`, traps, casos `sem_permissao`/`interrompido`); 6 → Task 2 (`toques_demais`, `toques_ok`); 7 → Task 1. +- **Placeholders:** nenhum; a única incerteza declarada é o comportamento de SIGTERM do bash, com o teste como árbitro. +- **Consistência:** `GPS_E2E_INTERVALO`, `max_toques`, `FAKE_DIALOGO_TOQUES/LIMITE`, `FAKE_DRIVE_DEMORA` usados com os mesmos nomes nas Tasks 2 e 3. +- **Risco aberto:** a prova em aparelho (Task 3, passo 3) depende do emulador-5554; sem ele, só a parte hermética e a análise estática são verificadas. diff --git a/scripts/qa/contagem_validation_report.py b/scripts/qa/contagem_validation_report.py index fe5abb3c..f65e9d03 100755 --- a/scripts/qa/contagem_validation_report.py +++ b/scripts/qa/contagem_validation_report.py @@ -4,7 +4,8 @@ A linha foi escrita à mão e ficou para trás quando RF13 mudou de status; este conferidor recalcula a partir das linhas `| RFnn | ... | **status** | ... |`. Status fora da lista conhecida é erro (exit 2), não linha ignorada: ignorar -deixaria a contagem errada de novo, em silêncio. +deixaria a contagem errada de novo, em silêncio. Linha de RF sem o status em +negrito também é erro (exit 2): ela sairia da contagem sem ninguém perceber. """ import pathlib import re @@ -12,12 +13,17 @@ ORDEM = ["backend", "backend + app", "app-only", "parcial", "ausente"] LINHA_RF = re.compile(r"^\| RF\d+ \|[^|]*\| \*\*([^*]+)\*\* \|", re.M) +LINHA_QUALQUER_RF = re.compile(r"^\| RF\d+ \|.*$", re.M) LINHA_CONTAGEM = re.compile(r"^\*\*Contagem:\*\* (.+)\.$", re.M) def contar(texto): contagem = {chave: 0 for chave in ORDEM} - for status in LINHA_RF.findall(texto): + for linha in LINHA_QUALQUER_RF.findall(texto): + achado = LINHA_RF.match(linha) + if achado is None: + raise ValueError(f"linha de RF sem status em negrito: {linha[:60]!r}") + status = achado.group(1) if status not in contagem: raise ValueError(f"status desconhecido na tabela de RF: {status!r}") contagem[status] += 1 diff --git a/scripts/qa/contagem_validation_report_test.py b/scripts/qa/contagem_validation_report_test.py index 7a61e52a..a79ecff0 100644 --- a/scripts/qa/contagem_validation_report_test.py +++ b/scripts/qa/contagem_validation_report_test.py @@ -25,6 +25,14 @@ def test_status_desconhecido_e_erro(self): with self.assertRaises(ValueError): contar("| RF09 | Z | **quase** | x |\n") + def test_status_sem_negrito_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF05 | X | parcial | x |\n") + + def test_linha_de_rf_malformada_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF05 | X |\n") + def test_sem_linha_de_contagem_e_erro(self): with self.assertRaises(ValueError): conferir("| RF01 | A | **backend** | x |\n") From 010dad6c13e1ff0a0ab71160286a8be395abf20a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 09:28:34 -0400 Subject: [PATCH 20/43] =?UTF-8?q?fix(qa):=20gps=20e2e=20limpa=20o=20tocado?= =?UTF-8?q?r=20antes=20dos=20tempor=C3=A1rios,=20sem=20define=20morto=20no?= =?UTF-8?q?=20drive=20e=20com=20caso=20herm=C3=A9tico=20de=20>2=20toques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/qa/acs_gps_e2e.sh | 33 ++++++++++++++++++++---------- scripts/qa/acs_gps_e2e_test.sh | 37 ++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh index cd01be5d..7dcfe9eb 100755 --- a/scripts/qa/acs_gps_e2e.sh +++ b/scripts/qa/acs_gps_e2e.sh @@ -3,7 +3,7 @@ # Permissão de localização do ACS (RF12) no emulador-5554. # # ./scripts/qa/acs_gps_e2e.sh # concedida: o app a enxerga (whileInUse) -# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # negada DE VEZ (USER_FIXED): "indisponível", sem travar +# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # negada DE VEZ (o Geolocator reporta deniedForever): "indisponível", sem travar # ... --reinstalar # autoriza apagar um app já instalado # # O diálogo de permissão é do sistema e o integration_test não o toca: o APK é @@ -57,12 +57,20 @@ toques="" amostrador="" instalamos=0 limpar() { + # Mata e ESPERA o tocador antes de apagar: o laço dele recria o arquivo de toques com `>>`. + if [[ -n "$amostrador" ]]; then + kill "$amostrador" 2>/dev/null || true + wait "$amostrador" 2>/dev/null || true + fi rm -f "$log" "$amostras" "$toques" - if [[ -n "$amostrador" ]]; then kill "$amostrador" 2>/dev/null || true; fi if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi } trap limpar EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +# O EXPECT_PERMISSION é fixado AQUI, no build: o `flutter drive` abaixo usa o APK pronto +# (--use-application-binary) e não recompila, então um dart-define no drive não teria efeito. if ! flutter build apk --debug --target=integration_test/geofence_gps_e2e.dart \ --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ --dart-define=EXPECT_PERMISSION="$expect" >"$log" 2>&1; then @@ -100,15 +108,19 @@ fi # O teste de widget do Flutter injeta toques no próprio Flutter e NÃO percebe um diálogo # do sistema aberto por cima do app. Quem percebe é o foco da janela, amostrado durante a execução. -# No cenário `denied_forever` o diálogo é ESPERADO (é o que o teste recusa para fixar a negação), -# então lá o que se confere é o número de toques do tocador: no máximo 2 (as duas recusas). +# No cenário `denied_forever` o diálogo é ESPERADO: o tocador abaixo o recusa. A prova principal de +# que a negação ficou fixada é do próprio teste (checkPermission() == deniedForever depois de abrir +# o painel). A contagem de toques é só uma trava complementar: no máximo $max_toques (as duas +# recusas); um 3º toque indica que o diálogo continuou aparecendo. Ela NÃO confere a flag USER_FIXED. +intervalo="${GPS_E2E_INTERVALO:-1}" +max_toques=2 amostras="$(mktemp)" toques="$(mktemp)" amostrar() { adb -s "$dev" shell dumpsys window 2>/dev/null | grep -m1 mCurrentFocus >>"$amostras" || true; } if [[ "$expect" == granted ]]; then - ( while true; do amostrar; sleep 1; done ) & + ( while true; do amostrar; sleep "$intervalo"; done ) & else - ( while true; do tocar_recusar; sleep 1; done ) & + ( while true; do tocar_recusar; sleep "$intervalo"; done ) & fi amostrador=$! @@ -116,16 +128,15 @@ drive_rc=0 limite="${GPS_E2E_TIMEOUT:-300}" timeout "$limite" flutter drive --driver=test_driver/integration_test.dart \ --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ - --use-application-binary="$apk" \ - --dart-define=EXPECT_PERMISSION="$expect" || drive_rc=$? + --use-application-binary="$apk" || drive_rc=$? if [[ "$expect" == granted ]]; then amostrar; fi # uma leitura final garante ao menos uma amostra -kill "$amostrador" 2>/dev/null || true; amostrador="" +kill "$amostrador" 2>/dev/null || true; wait "$amostrador" 2>/dev/null || true; amostrador="" if grep -q permissioncontroller "$amostras"; then echo "erro: o diálogo de permissão do sistema apareceu por cima do app durante o teste." >&2 exit 1 fi -if [[ "$(wc -l <"$toques")" -gt 2 ]]; then - echo "erro: o diálogo de permissão apareceu mais de 2 vezes: a permissão não ficou negada de vez." >&2 +if [[ "$(wc -l <"$toques")" -gt "$max_toques" ]]; then + echo "erro: o diálogo de permissão apareceu mais de $max_toques vezes: a permissão não ficou negada de vez." >&2 exit 1 fi if [[ "$drive_rc" -eq 124 ]]; then diff --git a/scripts/qa/acs_gps_e2e_test.sh b/scripts/qa/acs_gps_e2e_test.sh index fef6892b..0884c1fb 100755 --- a/scripts/qa/acs_gps_e2e_test.sh +++ b/scripts/qa/acs_gps_e2e_test.sh @@ -13,6 +13,14 @@ echo "adb $*" >>"$FAKE_LOG" case "$*" in *get-state*) echo device ;; *"pm list packages"*) [[ "${FAKE_INSTALADO:-0}" == 1 ]] && echo "package:br.com.prismrr.sinalacs.acs" ;; + *uiautomator*) + if [[ "${FAKE_DIALOGO_TOQUES:-0}" == 1 ]]; then + n="$(cat "$FAKE_LOG.dumps" 2>/dev/null || echo 0)" + if [[ -z "${FAKE_DIALOGO_LIMITE:-}" || "$n" -lt "$FAKE_DIALOGO_LIMITE" ]]; then + echo $((n + 1)) >"$FAKE_LOG.dumps" + echo '' + fi + fi ;; *"dumpsys window"*) [[ "${FAKE_DIALOGO:-0}" == 1 ]] && echo "mCurrentFocus=Window{1 u0 com.google.android.permissioncontroller/x.GrantPermissionsActivity}" ;; esac exit 0 @@ -22,7 +30,9 @@ cat >"$tmp/bin/flutter" <<'FIM' echo "flutter $*" >>"$FAKE_LOG" case "$1" in build) [[ "${FAKE_BUILD_FALHA:-0}" == 1 ]] && { echo "ERRO-FALSO-DO-GRADLE: sem espaço" >&2; exit 1; } ;; - drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1; [[ "${FAKE_DRIVE_TRAVA:-0}" == 1 ]] && sleep 30 ;; + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 + [[ "${FAKE_DRIVE_TRAVA:-0}" == 1 ]] && sleep 30 + [[ -n "${FAKE_DRIVE_DEMORA:-}" ]] && sleep "$FAKE_DRIVE_DEMORA" ;; esac exit 0 FIM @@ -32,8 +42,8 @@ falhas=0 caso() { # caso [VAR=valor ...] -- [args do script] nome=$1; shift envs=(); while [[ $# -gt 0 && $1 != -- ]]; do envs+=("$1"); shift; done; shift - : >"$FAKE_LOG" - saida="$(env PATH="$tmp/bin:$PATH" MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? + : >"$FAKE_LOG"; rm -rf "$tmp/t" "$FAKE_LOG.dumps"; mkdir "$tmp/t" + saida="$(env PATH="$tmp/bin:$PATH" TMPDIR="$tmp/t" GPS_E2E_INTERVALO=0.1 MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? } verifica() { eval "$1" || { echo "FALHOU [$nome]: $1"; echo "$saida" | tail -4; falhas=$((falhas + 1)); }; } conta() { grep -c "$1" "$FAKE_LOG" || true; } @@ -72,8 +82,27 @@ verifica 'grep -q "diálogo de permissão" <<<"$saida"' caso sem_permissao -- --sem-permissao verifica '[[ $codigo -eq 0 ]]' verifica '[[ $(conta "pm revoke") -eq 2 ]]' -verifica 'grep -q "EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' +verifica 'grep -q "^flutter build .*EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' # o define vale só no build +verifica '! grep "^flutter drive" "$FAKE_LOG" | grep -q EXPECT_PERMISSION' # o drive não o repete verifica '[[ $(conta "^flutter drive") -eq 1 ]]' # uma rodada só: o teste fixa a negação sozinho +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' # nenhum temporário sobra + +caso toques_ok FAKE_DIALOGO_TOQUES=1 FAKE_DIALOGO_LIMITE=2 FAKE_DRIVE_DEMORA=2 -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "input tap") -eq 2 ]]' # exatamente o teto: passa + +caso toques_demais FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=2 -- --sem-permissao +verifica '[[ $codigo -eq 1 ]]' +verifica '[[ $(conta "input tap") -gt 2 ]]' # o caminho de >2 foi exercitado de verdade +verifica 'grep -q "mais de 2 vezes" <<<"$saida"' +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' + +nome=interrompido; : >"$FAKE_LOG"; rm -rf "$tmp/t" "$FAKE_LOG.dumps"; mkdir "$tmp/t" +env PATH="$tmp/bin:$PATH" TMPDIR="$tmp/t" GPS_E2E_INTERVALO=0.1 MQTT_ACS_PASSWORD=x \ + FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=5 ./scripts/qa/acs_gps_e2e.sh --sem-permissao >/dev/null 2>&1 & +pid=$!; sleep 2; kill -TERM "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; saida=""; codigo=$? +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' # nem o arquivo de toques vaza +verifica '[[ $(conta uninstall) -ge 1 ]]' # e o app de teste é removido caso drive_trava FAKE_DRIVE_TRAVA=1 GPS_E2E_TIMEOUT=1 -- verifica '[[ $codigo -eq 124 ]]' From a28ac07241ce2f20c4d7cec349825f3b316f8d42 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 09:30:53 -0400 Subject: [PATCH 21/43] =?UTF-8?q?test(acs):=20gps=20e2e=20pede=20a=20permi?= =?UTF-8?q?ss=C3=A3o=20de=20novo=20depois=20de=20abrir=20o=20painel,=20no?= =?UTF-8?q?=20m=C3=A1ximo=202=20pedidos=20antes=20e=20coment=C3=A1rios=20s?= =?UTF-8?q?em=20USER=5FFIXED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acs/integration_test/geofence_gps_e2e.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/acs/integration_test/geofence_gps_e2e.dart b/apps/acs/integration_test/geofence_gps_e2e.dart index 281e37de..e0440946 100644 --- a/apps/acs/integration_test/geofence_gps_e2e.dart +++ b/apps/acs/integration_test/geofence_gps_e2e.dart @@ -36,13 +36,15 @@ void main() { expect(permission, anyOf(LocationPermission.whileInUse, LocationPermission.always), reason: 'o manifesto precisa declarar ACCESS_FINE_LOCATION para a permissão existir'); } else { - // Leva a permissão a negada-de-vez NA MESMA SESSÃO (o `flutter drive` desinstala o app ao + // Leva a permissão a negada de vez (o Geolocator reporta `deniedForever`) NA MESMA SESSÃO (o `flutter drive` desinstala o app ao // terminar, então o estado não sobrevive entre rodadas). Enquanto não está fixada, cada // `requestPermission()` abre o diálogo do sistema, que o script do emulador recusa; a // segunda recusa a torna permanente e o pedido passa a voltar na hora, sem diálogo. expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + // No máximo 2 pedidos: o script do emulador recusa o 1º diálogo e o 2º, e a 2ª recusa + // faz o Android parar de perguntar. Um 3º pedido seria só um pedido que já volta na hora. var resposta = await Geolocator.requestPermission(); - for (var i = 0; i < 2 && resposta != LocationPermission.deniedForever; i++) { + if (resposta != LocationPermission.deniedForever) { resposta = await Geolocator.requestPermission(); } expect(resposta, LocationPermission.deniedForever); @@ -68,6 +70,12 @@ void main() { await _pumpFor(tester, const Duration(seconds: 3)); if (_expect == 'denied_forever') { + // `checkPermission()` NÃO serve de prova aqui: no Android ele devolve `denied` mesmo com a + // negação fixada (só o resultado de `requestPermission()` carrega `deniedForever`). Com ela + // fixada, um novo pedido depois de abrir o painel volta `deniedForever` sem diálogo; se o + // diálogo reaparecesse, o tocador do script o recusaria e estouraria o teto de toques. + expect(await Geolocator.requestPermission(), LocationPermission.deniedForever, + reason: 'a permissão já fixada não pode voltar a abrir o diálogo'); // Review Focus 1: permissão negada nunca trava o ACS. expect(find.textContaining('Localização atual indisponível'), findsOneWidget); expect(find.byKey(const Key('geofence_visit')), findsOneWidget); From fadadb3604834f959e8c0756c037a1a1018be7a9 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 09:31:17 -0400 Subject: [PATCH 22/43] =?UTF-8?q?docs(qa):=20coment=C3=A1rio=20do=20tocado?= =?UTF-8?q?r=20de=20GPS=20descreve=20a=20prova=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/qa/acs_gps_e2e.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/qa/acs_gps_e2e.sh b/scripts/qa/acs_gps_e2e.sh index 7dcfe9eb..fa3fdf00 100755 --- a/scripts/qa/acs_gps_e2e.sh +++ b/scripts/qa/acs_gps_e2e.sh @@ -108,10 +108,11 @@ fi # O teste de widget do Flutter injeta toques no próprio Flutter e NÃO percebe um diálogo # do sistema aberto por cima do app. Quem percebe é o foco da janela, amostrado durante a execução. -# No cenário `denied_forever` o diálogo é ESPERADO: o tocador abaixo o recusa. A prova principal de -# que a negação ficou fixada é do próprio teste (checkPermission() == deniedForever depois de abrir -# o painel). A contagem de toques é só uma trava complementar: no máximo $max_toques (as duas -# recusas); um 3º toque indica que o diálogo continuou aparecendo. Ela NÃO confere a flag USER_FIXED. +# No cenário `denied_forever` o diálogo é ESPERADO: o tocador abaixo o recusa, 2 vezes (as duas +# recusas que fixam a negação). O teste ainda faz um 3º pedido depois de abrir o painel, que com a +# negação fixada volta na hora, sem diálogo: se o diálogo reaparecesse, o tocador o recusaria de novo +# e a contagem passaria de $max_toques. A contagem é o que detecta isso; ela NÃO confere a flag +# USER_FIXED do Android. intervalo="${GPS_E2E_INTERVALO:-1}" max_toques=2 amostras="$(mktemp)" From 196e0a2fd7ba82cc01095e895ffbced37c4345e9 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 09:56:40 -0400 Subject: [PATCH 23/43] =?UTF-8?q?docs:=20plano=20de=20revis=C3=A3o=20e=20f?= =?UTF-8?q?echamento=20do=20app=20ACS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-10-02-revisao-e-fechamento-do-app-acs.md | 806 ++++++++++++++++++ 1 file changed, 806 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md diff --git a/docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md b/docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md new file mode 100644 index 00000000..1a1a8958 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md @@ -0,0 +1,806 @@ +# Revisão e fechamento do app ACS — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Goal:** Fechar as lacunas que a revisão de 2026-10-02 mediu no app `apps/acs` — layout que estoura com fonte ampliada, configuração Android que deixa dado clínico sair por backup, a meta de sincronização offline sem teste e a documentação de tema desatualizada — e provar tudo no `emulator-5554`. + +**Architecture:** Quatro correções independentes, cada uma com um teste que falha antes: (1) o layout passa a aguentar 130% e 200% de fonte, com um harness no molde do `apps/admin`; (2) o manifesto proíbe backup automático e o APK de release é medido e iniciado no emulador; (3) a rajada de 100 visitas offline é provada em teste hermético e na jornada real do emulador; (4) a persistência do tema ganha teste e `spec/ui_design.md` deixa de dizer que o tema é fixo. + +**Tech Stack:** Flutter 3 / Dart, `flutter_test`, `integration_test`, `shared_preferences`, Gradle/aapt2, `scripts/qa/acs_full_e2e.sh` (banco de teste), emulador Android 16 em `emulator-5554`. + +**Spec:** `spec/PRD_system.md` (RF07–RF13, RNF02, RNF05, M2.4), `spec/ux_accessibility_assessment.md` (WCAG 1.4.4), `spec/lgpd_design.md` (§5.4 backup, INV-04), `spec/ui_design.md`, `apps/CLAUDE.md`. + +## Estado verificado em 2026-10-02 + +Medido nesta sessão, não herdado de documento: + +- `flutter analyze` em `apps/acs`: **No issues found**. `flutter test`: **208 passam**. Cobertura de linhas: `app.dart` 88,1%; `theme_controller.dart` 23,1%; `alert_feed.dart` 27,3%; `mqtt_secure_client.dart` 46,4%; `backend_client.dart` 53,9%. +- `emulator-5554` no ar (`adb devices`). `./scripts/qa/acs_full_e2e.sh` na linha de base: **OK** (login real, seletor por microárea, visita sincronizada). O script derruba a stack de desenvolvimento; `docker compose up -d` a restaura. +- **Achado 1 (defeito, RNF05/WCAG 1.4.4):** sonda descartável com `textScaleFactor` 1.3 e 2.0 em 360x800 estoura o layout: `app.dart:2230` (chip de conexão do cabeçalho, 94px a 130% e 308px a 200%), `app.dart:2213` (`_InfoRow`, até 370px), `app.dart:897` (botão "Atualizar dados da microárea", até 561px). A sonda parou na aba Área (um `pumpAndSettle` nunca assenta ali, porque o spinner do pull anima sem parar no fake): as abas Fila, Mapa, Visita e os itens de "Mais" **não foram medidos**. O `apps/admin` tem harness e teste para isso; o ACS, nenhum. +- **Achado 2 (configuração):** o `AndroidManifest.xml` `main` do ACS não declara `android:allowBackup`; o padrão do Android é `true`, então o backup automático pode levar para a nuvem do usuário o `shared_preferences` e o banco local da fila de visitas (dado de saúde, INV-04). `release` ainda assina com a chave de debug (`build.gradle.kts`). +- **Achado 3 (meta sem prova):** o PRD (M2.4) pede "100 registros offline sincronizam em < 5s após rede" e RNF02 pede sincronização > 99,5%. Nenhum teste do ACS enfileira 100 visitas; a fila manda **todas as pendentes num lote só** (`OfflineVisitQueue.sync`), sem teto de tamanho nem no cliente nem em `visits.sync`. +- **Achado 4 (documentação):** `spec/ui_design.md` §1.2 (linha 14) diz que o tema é "fixo e permanente" e que o app não reage a claro/escuro; o código tem `themeMode` + `darkTheme` + tela "Preferências" (Claro/Escuro/Automático, commit `45ae667`). A persistência (`ThemeController`) está em 23% de cobertura. + +## Fora deste plano (e por quê) + +- **RF08, cache persistido da microárea offline:** exige desenho de LGPD antes (já registrado em `PROGRESS.md`). +- **MFA/TOTP e refresh token:** adiados por decisão (`PROGRESS.md`). +- **iOS (`apps/acs/ios/` não existe), botão da UBS (sem fonte de contato), assinatura de release com chave própria, deploy de produção, mTLS no broker:** decisão de produto ou infraestrutura, sem dono neste plano. +- **`FLAG_SECURE` (bloquear captura de tela e miniatura nos "recentes"):** a lista da microárea mostra nome e condições crônicas; é decisão de produto/LGPD que ninguém registrou. Fica como pergunta ao dono do produto, não como código. +- **Testes de `alert_feed.dart` (`refused`/`unreachable`) e `backend_client.dart`:** `MqttAlertFeed.start` constrói o `MqttSecureClient` por dentro, sem ponto de injeção; testá-lo exigiria refatorar. Esses caminhos são provados no dispositivo por `smoke_test.dart`/`red_alert_cycle_test.dart` (stack de desenvolvimento). Não vale refatorar só para subir o número. +- **`app.dart` com 2266 linhas:** não dividir agora; nenhum achado depende disso. + +## Global Constraints + +- Português em comentários, mensagens de commit e texto de UI, como o resto do repositório. +- Commits **sem** atribuição de IA: nenhuma linha "Co-Authored-By", nenhuma "Generated with Claude Code" (regra do `CLAUDE.md` do projeto, que prevalece sobre o lembrete de atribuição da sessão). Tudo na branch `fix/app_acs`, sem push, merge nem PR. +- Nada de dado real de paciente em teste, log ou captura: só fixtures sintéticas. +- Cor clínica (`red`/`yellow`/`green`) usada como texto passa pelo token `*OnSurface` (`apps/CLAUDE.md`); este plano não mexe em cor. +- Triagem determinística e fila offline: preservar a semântica de retry/conflito/rejeição de `OfflineVisitQueue` (risco arquitetural nº 1 do `AGENTS.md`). +- O script de GPS e o de e2e nunca apagam um app já instalado sem `--reinstalar`; os novos passos de emulador seguem a mesma regra. +- Ambiente de todos os comandos: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin"`; raiz do repositório `/home/rock/Documents/Dev/APPs/SinalACS`. + +## Review Focus + +1. **Fonte a 200% em tela baixa ou com teclado aberto:** o formulário de visita (`VisitRegistrationScreen`) é o que mais cresce; a rolagem tem de alcançar o botão "Salvar e enfileirar sincronização". → Task 1, percurso com rolagem e `ensureVisible` do botão. +2. **Fonte alterada com o app já aberto** (Configurações → tamanho da fonte, app em segundo plano): o cabeçalho tem de crescer sem clipar. → Task 1, teste `o cabeçalho cresce quando a escala muda em tempo de execução`. +3. **Restauração de backup em outro aparelho:** o app tem de continuar abrindo e pedir login (a chave do Keystore não migra). → Task 2 prova `allowBackup=false` e que o APK de release sobe sem `FATAL` no emulador; a recuperação de chave perdida já é de `SqlCipherVisitStore`. +4. **Rajada com uma visita recusada no meio:** 100 visitas, uma `rejected` e uma `conflict` não podem travar as outras 98 nem sumir do aparelho. → Task 3, teste `uma recusada e um conflito no meio não seguram as demais`. +5. **Valor inválido gravado na preferência de tema** (arquivo de preferências corrompido ou de versão futura): o app abre em "Automático". → Task 4. + +--- + +### Task 1: Layout aguenta fonte ampliada (RNF05 / WCAG 1.4.4) + +**Files:** +- Create: `apps/acs/test/support/layout_harness.dart` +- Create: `apps/acs/test/text_scale_test.dart` +- Modify: `apps/acs/lib/app/app.dart` (linhas 227, 740, 897–903, 2213, 2214–2250) + +**Interfaces:** +- Consumes: `SinalAcsApp(backend:, feedBuilder:)`, `FakeAcsBackend`, `FakeAlertFeed`, `testAlert`, `testLocationCell` (`test/support/fakes.dart`); chaves `matricula_field`, `senha_field`, `login_button`. +- Produces (usados só dentro desta task e pela Task 3, que reaproveita `assentar`): + - `Future abrirPainel(WidgetTester tester, {required Size tamanho, double escalaDeFonte = 1.0})` + - `Future assentar(WidgetTester tester)` — o equivalente do `settleRealAsync` de `login_flow_test.dart` + - `void esperarSemEstouro(WidgetTester tester, String contexto)` + - `Future percorrerPainelInteiro(WidgetTester tester, String contexto)` + - `double acsHeaderHeight(BuildContext context)` em `app.dart` + +- [ ] **Step 1: Escrever o harness** + +Criar `apps/acs/test/support/layout_harness.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'fakes.dart'; + +/// Ferramentas para provar que as telas do ACS cabem na janela em que foram +/// postas. Mesmo método do `apps/admin/test/support/layout_harness.dart`, e as +/// mesmas três armadilhas — errar qualquer uma produz um teste que passa +/// sempre: +/// +/// 1. `RenderFlex` só denuncia o estouro quando **pinta**; checar antes de +/// assentar os quadros não vê nada. +/// 2. Item de `ListView` fora da viewport não é construído e nunca reclama; +/// daí [percorrerTelaInteira]. +/// 3. `takeException()` consome **uma** exceção por chamada; [esperarSemEstouro] +/// esvazia todas. +/// +/// Uma quarta é só do ACS: `pumpAndSettle()` **nunca assenta** na aba Área, porque +/// o spinner do pull anima sem parar contra o fake. [assentar] alterna quadros +/// e tempo real, como o `settleRealAsync` de `login_flow_test.dart`. + +const destinosDaBarra = ['Área', 'Fila', 'Mapa', 'Visita']; + +/// Itens da folha "Mais", pelo rótulo que `_moreItem` mostra. +const itensDoMais = [ + 'Acionamento', + 'Geofencing', + 'Avisos à comunidade', + 'Convidar paciente', + 'Preferências', +]; + +/// Troca o tamanho da janela de uma sessão já aberta. [tamanho] é em dp, porque +/// `devicePixelRatio` é forçado a 1. +void redimensionar(WidgetTester tester, Size tamanho, {double escalaDeFonte = 1.0}) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = tamanho; + tester.platformDispatcher.textScaleFactorTestValue = escalaDeFonte; +} + +Future assentar(WidgetTester tester) async { + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + } + await tester.pump(); +} + +/// Falha se algum quadro pintado desde a última checagem reportou estouro. +void esperarSemEstouro(WidgetTester tester, String contexto) { + final erros = []; + Object? erro; + while ((erro = tester.takeException()) != null) { + erros.add(erro.toString().split('\n').first); + } + expect(erros, isEmpty, reason: 'estouro de layout em $contexto: ${erros.join(' | ')}'); +} + +/// Abre o painel já logado (login pela tela, contra o `FakeAcsBackend`), numa +/// janela de tamanho e escala de fonte fixos. Confere a tela de login ANTES de +/// entrar: ela também tem cabeçalho. +Future abrirPainel( + WidgetTester tester, { + required Size tamanho, + double escalaDeFonte = 1.0, +}) async { + redimensionar(tester, tamanho, escalaDeFonte: escalaDeFonte); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final alerta = testAlert(alertId: 'escala-1', locationCell: testLocationCell); + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) { + queue.upsert(alerta); // `initialAlert` só seleciona; a tela lê a fila + return FakeAlertFeed(queue); + }, + )); + await tester.pump(); + esperarSemEstouro(tester, 'tela de login'); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); // numa janela baixa o botão fica abaixo da dobra + await tester.pump(); + await tester.tap(entrar); + await assentar(tester); +} + +/// Rola a tela até o fim, checando estouro a cada passo. +Future percorrerTelaInteira(WidgetTester tester, String contexto) async { + esperarSemEstouro(tester, '$contexto (topo)'); + final rolaveis = find.byType(Scrollable); + if (rolaveis.evaluate().isEmpty) return; // ex.: o mapa não rola + for (var passo = 1; passo <= 8; passo++) { + await tester.drag(rolaveis.last, const Offset(0, -320)); + await assentar(tester); + esperarSemEstouro(tester, '$contexto (rolagem $passo)'); + } +} + +Future irParaDaBarra(WidgetTester tester, String destino) async { + final alvo = find.descendant(of: find.byType(NavigationBar), matching: find.text(destino)); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $destino'); +} + +Future irParaDoMais(WidgetTester tester, String item) async { + final mais = find.descendant(of: find.byType(NavigationBar), matching: find.text('Mais')); + await tester.tap(mais); + await assentar(tester); + esperarSemEstouro(tester, 'folha "Mais"'); + final alvo = find.text(item); + await tester.ensureVisible(alvo); + await tester.pump(); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $item'); +} + +/// Visita os quatro destinos da barra e os cinco itens de "Mais". +Future percorrerPainelInteiro(WidgetTester tester, String contexto) async { + for (final destino in destinosDaBarra) { + await irParaDaBarra(tester, destino); + await percorrerTelaInteira(tester, '$contexto / $destino'); + } + for (final item in itensDoMais) { + await irParaDoMais(tester, item); + await percorrerTelaInteira(tester, '$contexto / $item'); + } +} +``` + +- [ ] **Step 2: Escrever o teste que falha** + +Criar `apps/acs/test/text_scale_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// WCAG 1.4.4 (RNF05): o conteúdo precisa sobreviver a 200% de escala de texto. +/// +/// O caminho é o layout aguentar, e não `MediaQuery.withClampedTextScaling`: +/// limitar a escala resolve o estouro desobedecendo à preferência de +/// acessibilidade de quem precisa dela. +void main() { + for (final escala in const [1.3, 2.0]) { + testWidgets('não estoura o layout com fonte a ${(escala * 100).toInt()}% em 360x800', (tester) async { + await abrirPainel(tester, tamanho: const Size(360, 800), escalaDeFonte: escala); + await percorrerPainelInteiro(tester, 'fonte a ${(escala * 100).toInt()}%'); + }); + } + + testWidgets('o cabeçalho cresce quando a escala muda em tempo de execução', (tester) async { + // No Android a preferência de tamanho de fonte muda em Configurações, com o + // app já aberto em segundo plano: o cabeçalho tem de acompanhar. + await abrirPainel(tester, tamanho: const Size(360, 800)); + final alturaPadrao = tester.getSize(find.byType(AppBar)).height; + + redimensionar(tester, const Size(360, 800), escalaDeFonte: 2.0); + await assentar(tester); + final alturaAmpliada = tester.getSize(find.byType(AppBar)).height; + + expect(alturaAmpliada, greaterThan(alturaPadrao)); + esperarSemEstouro(tester, 'cabeçalho com fonte a 200%'); + }); +} +``` + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart 2>&1 | tail -40` +Expected: **FAIL** nos três testes. Mensagens com `estouro de layout em ... A RenderFlex overflowed by N pixels on the right` (o `-N` do cabeçalho `1.3`/`2.0`). Anotar **todos** os pontos que o relatório lista: os três conhecidos (`app.dart:2230`, `2213`, `897`) e qualquer outro que apareça nas abas Fila, Mapa, Visita ou nos itens de "Mais" — esses ainda não foram medidos. Se o teste passar de primeira, ele não está pegando o estouro: pare e confira o harness (armadilhas 1–3 no topo dele). + +- [ ] **Step 4: Corrigir os três pontos conhecidos** + +(a) Botão do pull — `app.dart` ~897–903: o `Text` dentro de um `Row(mainAxisSize: min)` não quebra linha. Trocar + +```dart + const Text('Atualizar dados da microárea'), +``` +por +```dart + const Flexible(child: Text('Atualizar dados da microárea', textAlign: TextAlign.center)), +``` + +(b) `_InfoRow` — `app.dart:2213`: o rótulo à esquerda não encolhe. Trocar `Text(label)` por `Flexible(child: Text(label))` e dar um respiro entre os dois, ficando + +```dart +class _InfoRow extends StatelessWidget { const _InfoRow(this.label, this.value); final String label; final String value; @override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.symmetric(vertical: 5), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [Flexible(child: Text(label)), const SizedBox(width: 12), Flexible(child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)))])); } +``` + +(c) Cabeçalho — `app.dart` 2214–2250. A altura vem de quem monta o `Scaffold` (um `PreferredSizeWidget` não tem `BuildContext`; é o mesmo desenho do `adminHeaderHeight`). Acrescentar, antes de `_Header`: + +```dart +/// Altura do cabeçalho, crescendo com a escala de fonte. +/// +/// `preferredSize` não tem acesso ao `BuildContext`, então quem monta o +/// `Scaffold` calcula e entrega. O teto de 132 existe para que fonte a 200% não +/// coma metade da tela de um celular; o título já usa elipse. +double acsHeaderHeight(BuildContext context) => + MediaQuery.textScalerOf(context).scale(72).clamp(72.0, 132.0); +``` + +Trocar o construtor, o `preferredSize` e o chip: + +```dart +class _Header extends StatelessWidget implements PreferredSizeWidget { + const _Header(this.eyebrow, this.title, {required this.height, this.connected}); + + final String eyebrow; + final String title; + + /// Calculada por quem monta o Scaffold, via [acsHeaderHeight]. + final double height; + + /// Estado da conexão com o broker. `null` fora do painel. + /// + /// Fica no cabeçalho de propósito: um ACS precisa saber, sem procurar, que + /// parou de receber alertas. + final bool? connected; + + @override + Size get preferredSize => Size.fromHeight(height); +``` + +e, dentro de `actions`, limitar o chip e deixar o rótulo cortar em vez de estourar (o ícone e a cor continuam dizendo o estado; o texto completo vai no `Semantics`): + +```dart + Padding( + padding: const EdgeInsets.only(right: 12), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5), + child: Chip( + key: const Key('broker_status'), + avatar: connected == null ? null : Icon( + connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, + size: 18, + color: connected! ? context.acsRisk.greenOnSurface : context.acsRisk.redOnSurface, + ), + label: Text( + switch (connected) { + null => 'Offline ready', + true => 'Alertas em tempo real', + false => 'Sem central', + }, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ), +``` + +Os dois chamadores passam a altura (`app.dart:227` e `:740`): + +```dart + appBar: _Header('Segurança e rastreabilidade', 'Acesso institucional', height: acsHeaderHeight(context)), +``` +```dart + appBar: _Header('ACS • ${_brokerConnected ? 'em linha' : 'sem conexão'}', 'Painel operacional', height: acsHeaderHeight(context), connected: _brokerConnected), +``` +(O primeiro deixa de ser `const`.) + +- [ ] **Step 5: Rodar de novo e tratar o que sobrar** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart 2>&1 | tail -40` +Expected: se ainda falhar, cada falha nomeia a linha de `app.dart`. Corrija **cada ponto novo** com o mesmo padrão — `Flexible`/`Expanded` em filho de `Row`, `Wrap` no lugar de `Row` de chips, `maxLines`+`ellipsis` em rótulo secundário —, **sem** `withClampedTextScaling` e **sem** reduzir o tamanho da fonte. Rodar até **PASS 3/3**. Registrar no PROGRESS.md (Task 5) a lista final de pontos corrigidos. + +- [ ] **Step 6: Confirmar a regressão completa** + +Run: `cd apps/acs && flutter analyze && flutter test 2>&1 | tail -3` +Expected: `No issues found!` e `All tests passed!` com **≥ 211** testes (208 + 3). + +- [ ] **Step 7: Provar no emulador com fonte a 200%** + +O mapa é onde o layout real (fonte do Android, não a de teste) pode divergir. Usar o teste de integração que não precisa da stack: + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # tem de vir vazio (senão: pare, ver guarda abaixo) +adb -s emulator-5554 shell settings put system font_scale 2.0 +cd apps/acs && flutter test integration_test/map_flow_test.dart -d emulator-5554 \ + --dart-define=SINALACS_MQTT_PASSWORD="$(grep ^MQTT_ACS_PASSWORD ../../.env | cut -d= -f2-)" 2>&1 | tail -6 +adb -s emulator-5554 shell settings put system font_scale 1.0 +adb -s emulator-5554 uninstall br.com.prismrr.sinalacs.acs +``` +Expected: `All tests passed!`; sem `A RenderFlex overflowed` no log. Se o teste pedir a stack (o `map_flow_test` declara os mesmos pré-requisitos do `smoke_test`), ela está de pé: `docker compose ps` deve mostrar `sinalacs-serverpod … (healthy)`; se não, `docker compose up -d`. **Guarda:** se o primeiro comando listar o pacote, o app de dev está instalado e reinstalar apagaria a fila SQLCipher e a chave do Keystore: não prossiga sem o usuário autorizar. O `font_scale` volta a `1.0` mesmo se o teste falhar (rodar o penúltimo comando à mão). + +- [ ] **Step 8: Commit** + +```bash +git add apps/acs/test/support/layout_harness.dart apps/acs/test/text_scale_test.dart apps/acs/lib/app/app.dart +git commit -m "fix(acs): layout aguenta fonte a 130% e 200% (WCAG 1.4.4) com harness no molde do admin" +``` + +--- + +### Task 2: Manifesto sem backup automático e APK de release medido (INV-04 / LGPD) + +**Files:** +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml` +- Modify: `apps/acs/test/android_manifest_test.dart` + +**Interfaces:** +- Consumes: — +- Produces: nada que outra task use. + +- [ ] **Step 1: Escrever os testes que falham** + +Acrescentar em `apps/acs/test/android_manifest_test.dart`, antes do `}` final de `main()`: + +```dart + test('NÃO deixa o Android copiar o app para a nuvem (INV-04 / LGPD)', () { + // allowBackup é `true` por padrão. Sem `false` explícito, o backup + // automático leva para a conta Google da pessoa o `shared_preferences` e o + // banco local da fila de visitas — dado de saúde fora do controle do + // sistema. A chave do SQLCipher vive no Keystore e não migra, então o + // backup também não restauraria nada que prestasse: só vazaria. + expect( + RegExp(r']*android:allowBackup="false"', dotAll: true).hasMatch(manifest), + isTrue, + ); + }); + + test('o nome na gaveta do aparelho é legível, não o identificador do pacote', () { + expect(manifest, isNot(contains('android:label="sinalacs_acs"'))); + expect(manifest, contains('android:label="SinalACS ACS"')); + }); +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart 2>&1 | tail -15` +Expected: os 2 testes novos **FALHAM** (`Expected: true Actual: ` e `Expected: not contains 'android:label="sinalacs_acs"'`); os 4 antigos passam. + +- [ ] **Step 3: Implementar** + +Em `AndroidManifest.xml`, na tag ` +``` + +- [ ] **Step 4: Ver passar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart 2>&1 | tail -4` +Expected: `All tests passed!` (6 testes). + +- [ ] **Step 5: Medir o manifesto mesclado do APK de release e subi-lo no emulador** + +O teste lê o manifesto-fonte; o que vai para o aparelho é o **mesclado** (plugins acrescentam permissões). Medir o APK real: + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +set -a; source .env; set +a +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio, senão pare (guarda: reinstalar apaga a fila SQLCipher) +cd apps/acs +flutter build apk --release --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" 2>&1 | tail -4 +AAPT2="$(ls ~/Android/Sdk/build-tools/*/aapt2 | tail -1)" +"$AAPT2" dump permissions build/app/outputs/flutter-apk/app-release.apk +"$AAPT2" dump badging build/app/outputs/flutter-apk/app-release.apk | grep -E "application-label:|debuggable|allowBackup" || true +``` +Expected: `Built build/app/outputs/flutter-apk/app-release.apk`; as permissões são **exatamente** `INTERNET`, `ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION` (mais a `DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` interna do AndroidX, que não conta); `application-label:'SinalACS ACS'`; **sem** `application-debuggable`. Se aparecer qualquer outra permissão (em especial `ACCESS_BACKGROUND_LOCATION` ou `READ_*`/`WRITE_EXTERNAL_STORAGE`), **não siga**: é um plugin puxando permissão contra a decisão §4 — descobrir qual (`./gradlew :app:dependencies`) e registrar no PROGRESS.md antes de decidir. Se o build de release falhar (R8 removendo classe usada por reflexão), é achado real: copie o erro para o ledger e conserte antes de seguir. + +Subir e abrir no emulador, conferindo que não há falha de inicialização: + +```bash +adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-release.apk +adb -s emulator-5554 logcat -c +adb -s emulator-5554 shell monkey -p br.com.prismrr.sinalacs.acs -c android.intent.category.LAUNCHER 1 >/dev/null +sleep 8 +adb -s emulator-5554 shell pidof br.com.prismrr.sinalacs.acs +adb -s emulator-5554 logcat -d | grep -E "FATAL EXCEPTION|AndroidRuntime" | head -5 +adb -s emulator-5554 uninstall br.com.prismrr.sinalacs.acs +``` +Expected: `pidof` imprime um número (o processo está vivo depois de 8 s); o `grep` não imprime nada; o pacote é removido ao final (o app de release de teste não fica para a próxima rodada). + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/android/app/src/main/AndroidManifest.xml apps/acs/test/android_manifest_test.dart +git commit -m "fix(acs): manifesto proíbe backup automático e dá nome legível ao app; release medido no emulador" +``` + +--- + +### Task 3: Rajada de 100 visitas offline (M2.4 / RNF02) + +**Files:** +- Create: `apps/acs/test/offline_burst_test.dart` +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (novo `testWidgets` + imports) + +**Interfaces:** +- Consumes: `OfflineVisitQueue({VisitStore? store, VisitSynchronizer? synchronizer})`, `OfflineVisitRecord({required patientId, required risk, required status, localId, createdAt, ...})`, `InMemoryVisitStore`, `FakeVisitSynchronizer({statusFor, messageFor, throwOnPush})` com `.batches`, `SyncOutcomeKind`, `syntheticPatientId(int)` (`test/support/fakes.dart`); no dispositivo, `BackendClient`, `BackendVisitSynchronizer(backend:)`, `acsCredentialFromRelay()`, `e2ePatient('main')`. +- Produces: — + +- [ ] **Step 1: Escrever o teste hermético (falha por não existir o arquivo; vira vermelho de verdade com o `statusFor`)** + +Criar `apps/acs/test/offline_burst_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// M2.4 / RNF02: "100 registros offline sincronizam em < 5s após rede" e +/// sincronização > 99,5%. Aqui a parte determinística: nada se perde, nada +/// duplica, e a fila sobrevive a um reinício. O tempo contra o servidor de +/// verdade é medido no dispositivo (`integration_test/full_journey_e2e.dart`). +void main() { + OfflineVisitRecord visita(int n) => OfflineVisitRecord( + patientId: syntheticPatientId(n), + risk: 'yellow', + status: 'PENDENTE', + localId: 'burst-$n', + createdAt: DateTime.utc(2026, 10, 2, 8).add(Duration(minutes: n)), + ); + + test('100 visitas enfileiradas, reiniciadas e sincronizadas: nenhuma se perde', () async { + final disco = InMemoryVisitStore(); + final antes = OfflineVisitQueue(store: disco); + for (var n = 0; n < 100; n++) { + await antes.add(visita(n)); + } + expect(antes.pendingCount, 100); + + // Reinício do app: outra fila sobre o mesmo "disco". + final envio = FakeVisitSynchronizer(); + final depois = OfflineVisitQueue(store: disco, synchronizer: envio); + await depois.restore(); + expect(depois.pendingCount, 100, reason: 'o que estava no disco volta inteiro'); + + final resultado = await depois.sync(); + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(depois.pendingCount, 0); + expect(depois.syncedCount, 100); + expect(envio.batches, hasLength(1), reason: 'um lote só: uma ida e volta ao servidor'); + final enviados = envio.batches.single.map((v) => v.localId).toSet(); + expect(enviados, hasLength(100), reason: 'nenhum localId repetido (dedupe do servidor)'); + }); + + test('uma recusada e um conflito no meio não seguram as demais', () async { + final envio = FakeVisitSynchronizer( + statusFor: (v) => switch (v.localId) { + 'burst-40' => 'rejected', + 'burst-70' => 'conflict', + _ => 'synced', + }, + messageFor: (v) => v.localId == 'burst-40' ? 'paciente de outra microárea' : null, + ); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + await fila.sync(); + + expect(fila.syncedCount, 98); + expect(fila.rejectedCount, 1, reason: 'a recusada fica visível no aparelho até o ACS descartar'); + expect(fila.rejectedVisits.single.localId, 'burst-40'); + expect(fila.rejectedVisits.single.rejectionReason, 'paciente de outra microárea'); + expect(fila.conflictCount, 1, reason: 'o conflito fica registrado'); + // Conflito VOLTA para a fila (`_applyOutcomes`: "precisa de resolução, não de + // descarte"); a recusada, não — ela sai da retentativa e fica só visível. + expect(fila.pendingCount, 1); + expect(fila.pendingVisits.single.localId, 'burst-70'); + expect(fila.pendingVisits.single.status, 'CONFLITO'); + }); + + test('sem rede, as 100 continuam pendentes e a próxima tentativa as leva', () async { + final envio = FakeVisitSynchronizer(throwOnPush: true); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + final falha = await fila.sync(); + expect(falha.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 100, reason: 'falha de rede não perde o lote'); + + envio.throwOnPush = false; + await fila.sync(); + expect(fila.pendingCount, 0); + expect(fila.syncedCount, 100); + }); +} +``` + +- [ ] **Step 2: Rodar** + +Run: `cd apps/acs && flutter test test/offline_burst_test.dart 2>&1 | tail -20` +Expected: **PASS 3/3** — é um teste de **caracterização**: o comportamento já existe e este teste o trava (o contrato de conflito e recusa foi lido em `OfflineVisitQueue._applyOutcomes`: `conflict` entra em `_conflicts` **e** volta a `_pending` com status `CONFLITO`; `rejected` vai só para `_rejected`). Se algum falhar, é defeito real da fila (não do teste): pare e use systematic-debugging antes de seguir. Para provar que o teste enxerga uma regressão, trocar temporariamente `'burst-40' => 'rejected'` por `'burst-40' => 'synced'` deve fazer o 2º teste **falhar** (`Expected: <1> Actual: <0>`); desfazer. + +- [ ] **Step 3: Escrever a prova no dispositivo (falha antes de a fixture ser usada)** + +Em `apps/acs/integration_test/full_journey_e2e.dart`, acrescentar os imports + +```dart +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +``` + +e, dentro de `main()`, **depois** do `testWidgets` existente (mesmo nível), este teste. Ele usa o `BackendClient` real contra o banco de teste, sem tela: + +```dart + testWidgets('100 visitas offline sobem em menos de 5 s (M2.4)', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + final fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend)); + final base = DateTime.now().toUtc().subtract(const Duration(days: 1)); + for (var n = 0; n < 100; n++) { + // `localId` fica no padrão (um UUID novo): o protocolo o tipa como + // `UuidValue` e o servidor recusa o que não for UUID. + await fila.add(OfflineVisitRecord( + patientId: main.id, + risk: 'green', + status: 'PENDENTE', + createdAt: base.add(Duration(minutes: n)), + )); + } + final enviados = {for (final v in fila.pendingVisits) v.localId.toLowerCase()}; + expect(enviados, hasLength(100)); + + final relogio = Stopwatch()..start(); + final resultado = await fila.sync(); + relogio.stop(); + + expect(resultado.kind, SyncOutcomeKind.synced, reason: resultado.message); + expect(fila.syncedCount, 100); + expect(fila.pendingCount, 0); + // ignore: avoid_print + print('rajada: 100 visitas em ${relogio.elapsedMilliseconds} ms'); + expect(relogio.elapsed, lessThan(const Duration(seconds: 5)), + reason: 'PRD M2.4: 100 registros offline sincronizam em < 5 s'); + + final remotas = await backend.pullVisits(since: DateTime.fromMillisecondsSinceEpoch(0)); + final noServidor = {for (final v in remotas) v.localId.toString().toLowerCase()}; + expect(noServidor.containsAll(enviados), isTrue, + reason: 'as 100 visitas estão no servidor, conferidas por pull'); + }); +``` + +(`backend.login` e `pullVisits` são os métodos de `AcsBackend`: `Future login({required String matricula, required String senha})` e `Future> pullVisits({required DateTime since})`; `VisitSyncEntry.localId` é `UuidValue`, por isso o `toString()`.) + +- [ ] **Step 4: Rodar no emulador contra o banco de teste** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio, senão pare +./scripts/qa/acs_full_e2e.sh 2>&1 | tee /tmp/acs_full_rajada.log | tail -12 +docker compose up -d # o script derruba a stack de desenvolvimento +grep "rajada:" /tmp/acs_full_rajada.log +``` +Expected: `OK — jornada completa do ACS contra o banco de teste`; linha `rajada: 100 visitas em NNN ms` com NNN < 5000. Se o servidor recusar o lote (corpo grande, transação longa) ou passar de 5 s: **não relaxe o limite**. Meça onde o tempo vai (`docker compose logs backend`), e só então decida entre fatiar em lotes de 50 no `BackendVisitSynchronizer` (com um teste hermético novo que prove que o resultado final é idêntico) e registrar o gargalo no PROGRESS.md. Registrar a decisão como `Ruling:` no ledger. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/test/offline_burst_test.dart apps/acs/integration_test/full_journey_e2e.dart +git commit -m "test(acs): rajada de 100 visitas offline provada na fila e contra o servidor real no emulador" +``` + +--- + +### Task 4: Persistência do tema testada e `ui_design.md` corrigido + +**Files:** +- Create: `apps/acs/test/theme_controller_test.dart` +- Modify: `spec/ui_design.md:14` +- Modify: `spec/ux_ui_test_plan.md:170` + +**Interfaces:** +- Consumes: `ThemeController([ThemeMode value])`, `.restore()`, `.setThemeMode(ThemeMode)`, chave `theme_mode` em `SharedPreferences`. +- Produces: — + +- [ ] **Step 1: Escrever os testes** + +Criar `apps/acs/test/theme_controller_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sinalacs_acs/core/services/theme_controller.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('sem nada gravado, abre em Automático', () async { + final controller = ThemeController(); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('a escolha sobrevive a um reinício', () async { + final primeira = ThemeController(); + await primeira.setThemeMode(ThemeMode.light); + + final depoisDoReinicio = ThemeController(); + await depoisDoReinicio.restore(); + expect(depoisDoReinicio.value, ThemeMode.light); + }); + + test('valor inválido na preferência (arquivo corrompido ou de versão futura) cai em Automático', () async { + SharedPreferences.setMockInitialValues({'theme_mode': 'sepia'}); + final controller = ThemeController(ThemeMode.dark); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('o ouvinte é avisado ao trocar', () async { + final controller = ThemeController(); + var avisos = 0; + controller.addListener(() => avisos++); + await controller.setThemeMode(ThemeMode.dark); + expect(avisos, 1); + expect(controller.value, ThemeMode.dark); + }); +} +``` + +- [ ] **Step 2: Rodar** + +Run: `cd apps/acs && flutter test test/theme_controller_test.dart 2>&1 | tail -10` +Expected: **PASS 4/4** (caracterização de comportamento já existente). Para provar que o 3º teste enxerga uma regressão, trocar temporariamente `orElse: () => ThemeMode.system` em `theme_controller.dart` por `orElse: () => ThemeMode.dark` e rodar: o teste **falha** (`Expected: ThemeMode: Actual: ThemeMode:`); **desfazer** a troca (`git diff apps/acs/lib` deve sair vazio). + +- [ ] **Step 3: Corrigir a documentação** + +`spec/ui_design.md`, linha 14, trocar o parágrafo do "Dark Mode (§1.2)" por (preservar o marcador `* **Dark Mode (§1.2):**`): + +``` +* **Tema (§1.2):** O escuro é o padrão de identidade visual (economia de bateria e menos fadiga em campo), mas **não é fixo**: o ACS e o paciente têm `themeMode` com `darkTheme` e uma tela "Preferências" com Claro, Escuro e Automático (segue o sistema), persistida localmente (`ThemeController`). Os dois temas passam na mesma matriz de contraste WCAG (`contrast_tokens_test.dart` e `contrast_tokens_light_test.dart`). Esta linha dizia que o tema era "fixo e permanente"; deixou de valer com o commit `45ae667`. +``` + +`spec/ux_ui_test_plan.md`, linha 170, trocar `**[Resolvido] §1.2 (Dark Mode):** Documentado formalmente como fixo/permanente no documento de design.` por: + +``` +* **[Resolvido, revisto em 2026-10-02] §1.2 (Dark Mode):** A decisão de "fixo/permanente" foi superada: o app passou a oferecer Claro/Escuro/Automático (`45ae667`). O documento de design foi atualizado; o teste de regressão é `theme_controller_test.dart` mais as duas matrizes de contraste. +``` +(manter o marcador `*` que a linha já tem no início.) + +- [ ] **Step 4: Conferir links e testes** + +Run: `./scripts/qa/check_documentation_links.sh && (cd apps/acs && flutter test 2>&1 | tail -2)` +Expected: sem erro de link; `All tests passed!` com **≥ 218** testes (208 + 3 + 3 + 4). + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/test/theme_controller_test.dart spec/ui_design.md spec/ux_ui_test_plan.md +git commit -m "test(acs): persistência do tema coberta; ui_design deixa de dizer que o tema é fixo" +``` + +--- + +### Task 5: Registro e barra final no emulador + +**Files:** +- Modify: `PROGRESS.md` (seção "Finalização do app ACS (2026-10-01)") +- Modify: `apps/CLAUDE.md` (parágrafo "ACS: permissões, SAMU e e2e") + +**Interfaces:** nenhuma. + +- [ ] **Step 1: Registrar no `PROGRESS.md`** + +Depois do item "**Minors da revisão — fechados (2026-10-01).**" acrescentar: + +``` +- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% (cabeçalho, `_InfoRow`, botão do pull, mais o que o harness achou nas outras abas: ); corrigido, com `test/support/layout_harness.dart` e `test/text_scale_test.dart`. (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível; o APK de release foi medido (`aapt2 dump permissions`: só INTERNET e localização em primeiro plano) e subiu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`offline_burst_test.dart`) e contra o servidor real no emulador ( ms, meta < 5000). (4) **Tema:** `ThemeController` coberto e `ui_design.md` corrigido. **Continua aberto:** assinatura de release com chave própria, `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, MFA/refresh token, iOS, botão da UBS. +``` +(Substituir os dois marcadores `<...>` pelos valores reais medidos; não deixar os `<>` no arquivo.) + +- [ ] **Step 2: Registrar no `apps/CLAUDE.md`** + +No parágrafo "**ACS: permissões, SAMU e e2e (2026-10-01).**", acrescentar ao final: "O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s." + +- [ ] **Step 3: Barra final** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +(cd apps/acs && flutter analyze && flutter test 2>&1 | tail -2) +(cd scripts/qa && python3 contagem_validation_report_test.py && python3 otp_relay_test.py) +python3 scripts/qa/contagem_validation_report.py +./scripts/qa/lib_rele_test.sh && ./scripts/qa/acs_gps_e2e_test.sh +./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio +./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao +./scripts/qa/acs_full_e2e.sh; docker compose up -d +``` +Expected: `No issues found!`; `flutter test` **≥ 218** passam; Python `OK`; `ok: contagem do validation_report confere`; `ok: lib_rele` e `ok: acs_gps_e2e`; `ci_invariants` `ok`; links sem erro; os dois GPS `OK` (sem `--reinstalar`: o pacote tem de estar ausente); jornada `OK` com a linha `rajada:`; `sinalacs-serverpod … (healthy)` e `ls .e2e/fixtures.json` → inexistente. + +- [ ] **Step 4: Estado do repositório e commit** + +```bash +git status --short && git log --oneline -8 && git log -8 --format=%B | grep -ci "co-authored\|generated with" +git add PROGRESS.md apps/CLAUDE.md +git commit -m "docs: registra a revisão e o fechamento do app ACS" +``` +Expected: árvore limpa depois do commit; **`0`** na contagem de atribuições de IA; sem push. + +--- + +## Auto-revisão + +**Cobertura:** achado 1 → Task 1; achado 2 → Task 2; achado 3 → Task 3; achado 4 → Task 4; registro e barra no emulador → Task 5. Os itens "Fora deste plano" têm o motivo escrito e nenhum vira tarefa escondida. + +**Placeholders:** não há "TBD". Dois pontos dependem de medir e têm as duas saídas escritas: os pontos de estouro além dos três conhecidos (Task 1, Step 5: regra de correção, proibição de `withClampedTextScaling`) e o tempo da rajada contra o servidor (Task 3, Step 4: não relaxar o limite; medir; fatiar ou registrar). Os dois `<>` do PROGRESS.md têm instrução explícita de substituição. + +**Consistência de nomes:** `assentar`, `esperarSemEstouro`, `abrirPainel`, `percorrerPainelInteiro`, `redimensionar` definidos na Task 1 e usados só nela; `acsHeaderHeight` definido e usado nos dois chamadores de `_Header`; `syntheticPatientId`, `FakeVisitSynchronizer.batches/throwOnPush/statusFor/messageFor` batem com `test/support/fakes.dart`; `backend.login(matricula:, senha:)` e `pullVisits(since:)` batem com `AcsBackend`. + +**Review Focus:** as 5 linhas têm teste dono (1 e 2 na Task 1; 3 na Task 2; 4 e a rajada na Task 3; 5 na Task 4). + +**Riscos que só a execução resolve:** (1) quantos pontos de estouro existem fora da aba Área (a sonda parou ali); (2) se o release com R8 compila e abre; (3) se `visits.sync` aguenta 100 entradas em um lote dentro de 5 s no banco de teste; (4) se o `map_flow_test` roda sem a stack (Task 1, Step 7). + +**Conferido na escrita:** o contrato de conflito/recusa (`_applyOutcomes`: conflito volta a `_pending`, recusa não) e o tipo de `localId` (`UuidValue` no protocolo) foram lidos no código e corrigidos no plano antes de entregar. From 2130637cb50f5d27b3a95760dab12f07d4c03e91 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 10:04:38 -0400 Subject: [PATCH 24/43] fix(acs): layout aguenta fonte a 130% e 200% (WCAG 1.4.4) com harness no molde do admin --- apps/acs/lib/app/app.dart | 63 ++++++---- apps/acs/test/support/layout_harness.dart | 134 ++++++++++++++++++++++ apps/acs/test/text_scale_test.dart | 32 ++++++ 3 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 apps/acs/test/support/layout_harness.dart create mode 100644 apps/acs/test/text_scale_test.dart diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index c0c0842f..d31c12eb 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -224,7 +224,7 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: const _Header('Segurança e rastreabilidade', 'Acesso institucional'), + appBar: _Header('Segurança e rastreabilidade', 'Acesso institucional', height: acsHeaderHeight(context)), body: Center(child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), child: ListView(padding: const EdgeInsets.all(24), children: [ @@ -737,7 +737,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver @override Widget build(BuildContext context) => Scaffold( - appBar: _Header('ACS • ${_brokerConnected ? 'em linha' : 'sem conexão'}', 'Painel operacional', connected: _brokerConnected), + appBar: _Header('ACS • ${_brokerConnected ? 'em linha' : 'sem conexão'}', 'Painel operacional', height: acsHeaderHeight(context), connected: _brokerConnected), body: SafeArea(child: switch (destination) { AcsDestination.area => TerritorializationScreen( pulling: _pullingVisits, @@ -798,13 +798,13 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver destinations: const [NavigationDestination(icon: Icon(Icons.storage_outlined), label: 'Área'), NavigationDestination(icon: Icon(Icons.grid_view_outlined), label: 'Fila'), NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Mapa'), NavigationDestination(icon: Icon(Icons.assignment_outlined), label: 'Visita'), NavigationDestination(icon: Icon(Icons.more_horiz), label: 'Mais')], ), ); - void _more(BuildContext context) => showModalBottomSheet(context: context, builder: (sheet) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [ + void _more(BuildContext context) => showModalBottomSheet(context: context, builder: (sheet) => SafeArea(child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [ _moreItem(sheet, Icons.call_outlined, 'Acionamento', AcsDestination.escalation), _moreItem(sheet, Icons.location_searching, 'Geofencing', AcsDestination.geofencing), _moreItem(sheet, Icons.campaign_outlined, 'Avisos à comunidade', AcsDestination.notices), _moreItem(sheet, Icons.qr_code_2, 'Convidar paciente', AcsDestination.invite), _moreItem(sheet, Icons.tune_outlined, 'Preferências', AcsDestination.settings), - ]))); + ])))); Widget _moreItem(BuildContext sheet, IconData icon, String label, AcsDestination value) => ListTile(leading: Icon(icon), title: Text(label), onTap: () { Navigator.pop(sheet); setState(() => destination = value); }); } @@ -899,7 +899,7 @@ class TerritorializationScreen extends StatelessWidget { const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 8), ], - const Text('Atualizar dados da microárea'), + const Flexible(child: Text('Atualizar dados da microárea', textAlign: TextAlign.center)), ]), ), ), @@ -1838,7 +1838,7 @@ class _VisitRegistrationScreenState extends State { contentPadding: EdgeInsets.zero, ), const SizedBox(height: 8), - DropdownButtonFormField(initialValue: outcome, decoration: const InputDecoration(labelText: 'Status do atendimento'), items: const ['Realizada com sucesso', 'Paciente ausente', 'Recusou atendimento'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), onChanged: !hasPatient || !_arrivalConfirmed ? null : (v) => setState(() => outcome = v!)), + DropdownButtonFormField(isExpanded: true, initialValue: outcome, decoration: const InputDecoration(labelText: 'Status do atendimento'), items: const ['Realizada com sucesso', 'Paciente ausente', 'Recusou atendimento'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), onChanged: !hasPatient || !_arrivalConfirmed ? null : (v) => setState(() => outcome = v!)), const SizedBox(height: 16), TextField( controller: notes, @@ -2210,13 +2210,24 @@ String _riskLabelPt(String riskLevel) => switch (riskLevel.toLowerCase()) { 'green' || 'verde' => 'Verde', _ => 'Não classificado', }; -class _InfoRow extends StatelessWidget { const _InfoRow(this.label, this.value); final String label; final String value; @override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.symmetric(vertical: 5), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(label), Flexible(child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)))])); } +class _InfoRow extends StatelessWidget { const _InfoRow(this.label, this.value); final String label; final String value; @override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.symmetric(vertical: 5), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [Flexible(child: Text(label)), const SizedBox(width: 12), Flexible(child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)))])); } +/// Altura do cabeçalho, crescendo com a escala de fonte. +/// +/// `preferredSize` não tem acesso ao `BuildContext`, então quem monta o +/// `Scaffold` calcula e entrega. O teto de 132 existe para que fonte a 200% não +/// coma metade da tela de um celular; o título já usa elipse. +double acsHeaderHeight(BuildContext context) => + MediaQuery.textScalerOf(context).scale(72).clamp(72.0, 132.0); + class _Header extends StatelessWidget implements PreferredSizeWidget { - const _Header(this.eyebrow, this.title, {this.connected}); + const _Header(this.eyebrow, this.title, {required this.height, this.connected}); final String eyebrow; final String title; + /// Calculada por quem monta o Scaffold, via [acsHeaderHeight]. + final double height; + /// Estado da conexão com o broker. `null` fora do painel. /// /// Fica no cabeçalho de propósito: um ACS precisa saber, sem procurar, que @@ -2224,29 +2235,37 @@ class _Header extends StatelessWidget implements PreferredSizeWidget { final bool? connected; @override - Size get preferredSize => const Size.fromHeight(72); + Size get preferredSize => Size.fromHeight(height); @override Widget build(BuildContext context) => AppBar( + toolbarHeight: height, title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(eyebrow.toUpperCase(), style: TextStyle(fontSize: 10, color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold)), - Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text(eyebrow.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold)), + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ]), actions: [ Padding( padding: const EdgeInsets.only(right: 12), - child: Chip( - key: const Key('broker_status'), - avatar: connected == null ? null : Icon( - connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, - size: 18, - color: connected! ? context.acsRisk.greenOnSurface : context.acsRisk.redOnSurface, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5), + child: Chip( + key: const Key('broker_status'), + avatar: connected == null ? null : Icon( + connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, + size: 18, + color: connected! ? context.acsRisk.greenOnSurface : context.acsRisk.redOnSurface, + ), + label: Text( + switch (connected) { + null => 'Offline ready', + true => 'Alertas em tempo real', + false => 'Sem central', + }, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), - label: Text(switch (connected) { - null => 'Offline ready', - true => 'Alertas em tempo real', - false => 'Sem central', - }), ), ), ], diff --git a/apps/acs/test/support/layout_harness.dart b/apps/acs/test/support/layout_harness.dart new file mode 100644 index 00000000..7b345ab6 --- /dev/null +++ b/apps/acs/test/support/layout_harness.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'fakes.dart'; + +/// Ferramentas para provar que as telas do ACS cabem na janela em que foram +/// postas. Mesmo método do `apps/admin/test/support/layout_harness.dart`, e as +/// mesmas três armadilhas — errar qualquer uma produz um teste que passa +/// sempre: +/// +/// 1. `RenderFlex` só denuncia o estouro quando **pinta**; checar antes de +/// assentar os quadros não vê nada. +/// 2. Item de `ListView` fora da viewport não é construído e nunca reclama; +/// daí [percorrerTelaInteira]. +/// 3. `takeException()` consome **uma** exceção por chamada; [esperarSemEstouro] +/// esvazia todas. +/// +/// Uma quarta é só do ACS: `pumpAndSettle()` **nunca assenta** na aba Área, porque +/// o spinner do pull anima sem parar contra o fake. [assentar] alterna quadros +/// e tempo real, como o `settleRealAsync` de `login_flow_test.dart`. + +const destinosDaBarra = ['Área', 'Fila', 'Mapa', 'Visita']; + +/// Itens da folha "Mais", pelo rótulo que `_moreItem` mostra. +const itensDoMais = [ + 'Acionamento', + 'Geofencing', + 'Avisos à comunidade', + 'Convidar paciente', + 'Preferências', +]; + +/// Troca o tamanho da janela de uma sessão já aberta. [tamanho] é em dp, porque +/// `devicePixelRatio` é forçado a 1. +void redimensionar(WidgetTester tester, Size tamanho, {double escalaDeFonte = 1.0}) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = tamanho; + tester.platformDispatcher.textScaleFactorTestValue = escalaDeFonte; +} + +Future assentar(WidgetTester tester) async { + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + } + await tester.pump(); +} + +/// Falha se algum quadro pintado desde a última checagem reportou estouro. +void esperarSemEstouro(WidgetTester tester, String contexto) { + final erros = []; + Object? erro; + while ((erro = tester.takeException()) != null) { + erros.add(erro.toString().split('\n').first); + } + expect(erros, isEmpty, reason: 'estouro de layout em $contexto: ${erros.join(' | ')}'); +} + +/// Abre o painel já logado (login pela tela, contra o `FakeAcsBackend`), numa +/// janela de tamanho e escala de fonte fixos. Confere a tela de login ANTES de +/// entrar: ela também tem cabeçalho. +Future abrirPainel( + WidgetTester tester, { + required Size tamanho, + double escalaDeFonte = 1.0, +}) async { + redimensionar(tester, tamanho, escalaDeFonte: escalaDeFonte); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final alerta = testAlert(alertId: 'escala-1', locationCell: testLocationCell); + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) { + queue.upsert(alerta); // `initialAlert` só seleciona; a tela lê a fila + return FakeAlertFeed(queue); + }, + )); + await tester.pump(); + esperarSemEstouro(tester, 'tela de login'); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); // numa janela baixa o botão fica abaixo da dobra + await tester.pump(); + await tester.tap(entrar); + await assentar(tester); +} + +/// Rola a tela até o fim, checando estouro a cada passo. +Future percorrerTelaInteira(WidgetTester tester, String contexto) async { + esperarSemEstouro(tester, '$contexto (topo)'); + final rolaveis = find.byType(Scrollable); + if (rolaveis.evaluate().isEmpty) return; // ex.: o mapa não rola + for (var passo = 1; passo <= 8; passo++) { + await tester.drag(rolaveis.last, const Offset(0, -320)); + await assentar(tester); + esperarSemEstouro(tester, '$contexto (rolagem $passo)'); + } +} + +Future irParaDaBarra(WidgetTester tester, String destino) async { + final alvo = find.descendant(of: find.byType(NavigationBar), matching: find.text(destino)); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $destino'); +} + +Future irParaDoMais(WidgetTester tester, String item) async { + final mais = find.descendant(of: find.byType(NavigationBar), matching: find.text('Mais')); + await tester.tap(mais); + await assentar(tester); + esperarSemEstouro(tester, 'folha "Mais"'); + final alvo = find.text(item); + await tester.ensureVisible(alvo); + await tester.pump(); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $item'); +} + +/// Visita os quatro destinos da barra e os cinco itens de "Mais". +Future percorrerPainelInteiro(WidgetTester tester, String contexto) async { + for (final destino in destinosDaBarra) { + await irParaDaBarra(tester, destino); + await percorrerTelaInteira(tester, '$contexto / $destino'); + } + for (final item in itensDoMais) { + await irParaDoMais(tester, item); + await percorrerTelaInteira(tester, '$contexto / $item'); + } +} diff --git a/apps/acs/test/text_scale_test.dart b/apps/acs/test/text_scale_test.dart new file mode 100644 index 00000000..c548c6bb --- /dev/null +++ b/apps/acs/test/text_scale_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// WCAG 1.4.4 (RNF05): o conteúdo precisa sobreviver a 200% de escala de texto. +/// +/// O caminho é o layout aguentar, e não `MediaQuery.withClampedTextScaling`: +/// limitar a escala resolve o estouro desobedecendo à preferência de +/// acessibilidade de quem precisa dela. +void main() { + for (final escala in const [1.3, 2.0]) { + testWidgets('não estoura o layout com fonte a ${(escala * 100).toInt()}% em 360x800', (tester) async { + await abrirPainel(tester, tamanho: const Size(360, 800), escalaDeFonte: escala); + await percorrerPainelInteiro(tester, 'fonte a ${(escala * 100).toInt()}%'); + }); + } + + testWidgets('o cabeçalho cresce quando a escala muda em tempo de execução', (tester) async { + // No Android a preferência de tamanho de fonte muda em Configurações, com o + // app já aberto em segundo plano: o cabeçalho tem de acompanhar. + await abrirPainel(tester, tamanho: const Size(360, 800)); + final alturaPadrao = tester.getSize(find.byType(AppBar)).height; + + redimensionar(tester, const Size(360, 800), escalaDeFonte: 2.0); + await assentar(tester); + final alturaAmpliada = tester.getSize(find.byType(AppBar)).height; + + expect(alturaAmpliada, greaterThan(alturaPadrao)); + esperarSemEstouro(tester, 'cabeçalho com fonte a 200%'); + }); +} From ff4961e1c4853631f3ff8ac8940137aac544d189 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 10:06:11 -0400 Subject: [PATCH 25/43] =?UTF-8?q?fix(acs):=20manifesto=20pro=C3=ADbe=20bac?= =?UTF-8?q?kup=20autom=C3=A1tico=20e=20d=C3=A1=20nome=20leg=C3=ADvel=20ao?= =?UTF-8?q?=20app;=20release=20medido=20no=20emulador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/app/src/main/AndroidManifest.xml | 3 ++- apps/acs/test/android_manifest_test.dart | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/acs/android/app/src/main/AndroidManifest.xml b/apps/acs/android/app/src/main/AndroidManifest.xml index 29d3419a..0b1e8f4b 100644 --- a/apps/acs/android/app/src/main/AndroidManifest.xml +++ b/apps/acs/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ ]*android:allowBackup="false"', dotAll: true).hasMatch(manifest), + isTrue, + ); + }); + + test('o nome na gaveta do aparelho é legível, não o identificador do pacote', () { + expect(manifest, isNot(contains('android:label="sinalacs_acs"'))); + expect(manifest, contains('android:label="SinalACS ACS"')); + }); } From 5310215b0a63ecb1a70e939d21433d3bc982091f Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 10:08:59 -0400 Subject: [PATCH 26/43] test(acs): rajada de 100 visitas offline provada na fila e contra o servidor real no emulador --- .../integration_test/full_journey_e2e.dart | 43 +++++++++ .../acs/integration_test/support/e2e_acs.dart | 11 ++- apps/acs/test/offline_burst_test.dart | 87 +++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 apps/acs/test/offline_burst_test.dart diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index 673fe20e..db996328 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -25,6 +25,8 @@ import 'package:integration_test/integration_test.dart'; import 'package:sinalacs_acs/app/app.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' as api; import 'support/e2e_acs.dart'; @@ -106,4 +108,45 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(seconds: 1)); }); + + testWidgets('100 visitas offline sobem em menos de 5 s (M2.4)', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + final fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend)); + final base = DateTime.now().toUtc().subtract(const Duration(days: 1)); + for (var n = 0; n < 100; n++) { + // `localId` fica no padrão (um UUID novo): o protocolo o tipa como + // `UuidValue` e o servidor recusa o que não for UUID. + await fila.add(OfflineVisitRecord( + patientId: main.id, + risk: 'green', + status: 'PENDENTE', + createdAt: base.add(Duration(minutes: n)), + )); + } + final enviados = {for (final v in fila.pendingVisits) v.localId.toLowerCase()}; + expect(enviados, hasLength(100)); + + final relogio = Stopwatch()..start(); + final resultado = await fila.sync(); + relogio.stop(); + + expect(resultado.kind, SyncOutcomeKind.synced, reason: resultado.message); + expect(fila.syncedCount, 100); + expect(fila.pendingCount, 0); + // ignore: avoid_print + print('rajada: 100 visitas em ${relogio.elapsedMilliseconds} ms'); + expect(relogio.elapsed, lessThan(const Duration(seconds: 5)), + reason: 'PRD M2.4: 100 registros offline sincronizam em < 5 s'); + + final remotas = await backend.pullVisits(since: DateTime.fromMillisecondsSinceEpoch(0)); + final noServidor = {for (final v in remotas) v.localId.toString().toLowerCase()}; + expect(noServidor.containsAll(enviados), isTrue, + reason: 'as 100 visitas estão no servidor, conferidas por pull'); + }); } diff --git a/apps/acs/integration_test/support/e2e_acs.dart b/apps/acs/integration_test/support/e2e_acs.dart index 769aa4c8..a5c2d8ec 100644 --- a/apps/acs/integration_test/support/e2e_acs.dart +++ b/apps/acs/integration_test/support/e2e_acs.dart @@ -61,7 +61,16 @@ Future _get(String path, {String query = ''}) async { } /// Matrícula e senha do ACS da fixture, servidas pelo relé (opt-in). -Future<({String matricula, String senha})> acsCredentialFromRelay() async { +/// +/// O relé entrega `/acs` **uma única vez** por execução (a senha sintética não +/// fica servida), mas vários testes do mesmo arquivo precisam dela: a primeira +/// chamada busca e as seguintes reaproveitam. Todos rodam no mesmo processo. +Future<({String matricula, String senha})> acsCredentialFromRelay() => + _acsCredential ??= _buscarCredencialDoAcs(); + +Future<({String matricula, String senha})>? _acsCredential; + +Future<({String matricula, String senha})> _buscarCredencialDoAcs() async { final json = jsonDecode(await _get('/acs')) as Map; return (matricula: json['matricula'] as String, senha: json['senha'] as String); } diff --git a/apps/acs/test/offline_burst_test.dart b/apps/acs/test/offline_burst_test.dart new file mode 100644 index 00000000..02a855df --- /dev/null +++ b/apps/acs/test/offline_burst_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// M2.4 / RNF02: "100 registros offline sincronizam em < 5s após rede" e +/// sincronização > 99,5%. Aqui a parte determinística: nada se perde, nada +/// duplica, e a fila sobrevive a um reinício. O tempo contra o servidor de +/// verdade é medido no dispositivo (`integration_test/full_journey_e2e.dart`). +void main() { + OfflineVisitRecord visita(int n) => OfflineVisitRecord( + patientId: syntheticPatientId(n), + risk: 'yellow', + status: 'PENDENTE', + localId: 'burst-$n', + createdAt: DateTime.utc(2026, 10, 2, 8).add(Duration(minutes: n)), + ); + + test('100 visitas enfileiradas, reiniciadas e sincronizadas: nenhuma se perde', () async { + final disco = InMemoryVisitStore(); + final antes = OfflineVisitQueue(store: disco); + for (var n = 0; n < 100; n++) { + await antes.add(visita(n)); + } + expect(antes.pendingCount, 100); + + // Reinício do app: outra fila sobre o mesmo "disco". + final envio = FakeVisitSynchronizer(); + final depois = OfflineVisitQueue(store: disco, synchronizer: envio); + await depois.restore(); + expect(depois.pendingCount, 100, reason: 'o que estava no disco volta inteiro'); + + final resultado = await depois.sync(); + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(depois.pendingCount, 0); + expect(depois.syncedCount, 100); + expect(envio.batches, hasLength(1), reason: 'um lote só: uma ida e volta ao servidor'); + final enviados = envio.batches.single.map((v) => v.localId).toSet(); + expect(enviados, hasLength(100), reason: 'nenhum localId repetido (dedupe do servidor)'); + }); + + test('uma recusada e um conflito no meio não seguram as demais', () async { + final envio = FakeVisitSynchronizer( + statusFor: (v) => switch (v.localId) { + 'burst-40' => 'rejected', + 'burst-70' => 'conflict', + _ => 'synced', + }, + messageFor: (v) => v.localId == 'burst-40' ? 'paciente de outra microárea' : null, + ); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + await fila.sync(); + + expect(fila.syncedCount, 98); + expect(fila.rejectedCount, 1, reason: 'a recusada fica visível no aparelho até o ACS descartar'); + expect(fila.rejectedVisits.single.localId, 'burst-40'); + expect(fila.rejectedVisits.single.rejectionReason, 'paciente de outra microárea'); + expect(fila.conflictCount, 1, reason: 'o conflito fica registrado'); + // Conflito VOLTA para a fila (`_applyOutcomes`: "precisa de resolução, não de + // descarte"); a recusada, não — ela sai da retentativa e fica só visível. + expect(fila.pendingCount, 1); + expect(fila.pendingVisits.single.localId, 'burst-70'); + expect(fila.pendingVisits.single.status, 'CONFLITO'); + }); + + test('sem rede, as 100 continuam pendentes e a próxima tentativa as leva', () async { + final envio = FakeVisitSynchronizer(throwOnPush: true); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + final falha = await fila.sync(); + expect(falha.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 100, reason: 'falha de rede não perde o lote'); + + envio.throwOnPush = false; + await fila.sync(); + expect(fila.pendingCount, 0); + expect(fila.syncedCount, 100); + }); +} From 5cdf9c204ec7cd8aefb15fddbbc50617bb824bfc Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 10:11:28 -0400 Subject: [PATCH 27/43] =?UTF-8?q?test(acs):=20persist=C3=AAncia=20do=20tem?= =?UTF-8?q?a=20coberta;=20ui=5Fdesign=20deixa=20de=20dizer=20que=20o=20tem?= =?UTF-8?q?a=20=C3=A9=20fixo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acs/test/theme_controller_test.dart | 39 ++++++++++++++++++++++++ spec/ui_design.md | 2 +- spec/ux_ui_test_plan.md | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 apps/acs/test/theme_controller_test.dart diff --git a/apps/acs/test/theme_controller_test.dart b/apps/acs/test/theme_controller_test.dart new file mode 100644 index 00000000..eb3c5e51 --- /dev/null +++ b/apps/acs/test/theme_controller_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sinalacs_acs/core/services/theme_controller.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('sem nada gravado, abre em Automático', () async { + final controller = ThemeController(); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('a escolha sobrevive a um reinício', () async { + final primeira = ThemeController(); + await primeira.setThemeMode(ThemeMode.light); + + final depoisDoReinicio = ThemeController(); + await depoisDoReinicio.restore(); + expect(depoisDoReinicio.value, ThemeMode.light); + }); + + test('valor inválido na preferência (arquivo corrompido ou de versão futura) cai em Automático', () async { + SharedPreferences.setMockInitialValues({'theme_mode': 'sepia'}); + final controller = ThemeController(ThemeMode.dark); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('o ouvinte é avisado ao trocar', () async { + final controller = ThemeController(); + var avisos = 0; + controller.addListener(() => avisos++); + await controller.setThemeMode(ThemeMode.dark); + expect(avisos, 1); + expect(controller.value, ThemeMode.dark); + }); +} diff --git a/spec/ui_design.md b/spec/ui_design.md index 5ca1ee21..b8e1a9e5 100644 --- a/spec/ui_design.md +++ b/spec/ui_design.md @@ -11,7 +11,7 @@ O design restringe completamente as cores de emergência (Vermelho, Amarelo, Ver ## Atualizações de Design e Decisões de Produto (Pós-Auditoria de UX) * **Containers e Largura (max-w-2xl) (§1.1 e §3.4):** Fica decidido que o uso do `ConstrainedBox` com `Center` aplica-se **exclusivamente à tela de Login**. Telas pós-login, incluindo o formulário de Visita do ACS, não possuem restrição de largura máxima, estendendo-se de borda a borda. A menção ao `max-w-2xl` neste documento é revogada para as telas pós-login mobile. -* **Dark Mode (§1.2):** Fica documentado explicitamente que a definição `brightness: Brightness.dark` nos temas é **fixa e permanente**. O app não reage às preferências de modo claro/escuro do Sistema Operacional. +* **Tema (§1.2):** O escuro é o padrão de identidade visual (economia de bateria e menos fadiga em campo), mas **não é fixo**: o ACS e o paciente têm `themeMode` com `darkTheme` e uma tela "Preferências" com Claro, Escuro e Automático (segue o sistema), persistida localmente (`ThemeController`). Os dois temas passam na mesma matriz de contraste WCAG (`contrast_tokens_test.dart` e `contrast_tokens_light_test.dart`). Esta linha dizia que o tema era "fixo e permanente"; deixou de valer com o commit `45ae667`. * **Microinterações (§1.4):** Registra-se a equivalência intencional: o efeito de *ripple* nativo do Material 3 é o equivalente adotado no Flutter para a microinteração descrita nos protótipos como `active:scale`. * **Exceção de Cor (§3.1):** O uso da cor verde (`AcsColors.green`) no botão/texto de "Local alcançado" (geofencing) é uma **exceção documentada** à restrição clínica. O verde atua aqui como status operacional universal ("liberado") e não entra em conflito cognitivo com o risco clínico. * **Fricção do Fluxo de Emergência (§2.1):** Define-se como limiar quantitativo no PRD: o fluxo de acionamento do Botão de Emergência do Paciente deve custar **no máximo 4 toques** e durar **no máximo 10 segundos** em condições normais de rede. diff --git a/spec/ux_ui_test_plan.md b/spec/ux_ui_test_plan.md index aa38b91e..8c575f3f 100644 --- a/spec/ux_ui_test_plan.md +++ b/spec/ux_ui_test_plan.md @@ -167,7 +167,7 @@ Complementa (não duplica) o teste funcional de `offline_visit_queue.dart` já c **Decisão Geral:** Todos os achados foram formalizados através de atualizações explícitas nas regras do arquivo `spec/ui_design.md`, sem necessidade de regressão no código nativo. * **[Resolvido] §1.1 e §3.4 (Container Responsivo):** Decisão de Produto (A) acatada. Confirmado que a restrição de container aplica-se apenas ao Login. Telas pós-login mantêm comportamento expansível nativo (borda a borda). Regra atualizada em `ui_design.md`. -* **[Resolvido] §1.2 (Dark Mode):** Documentado formalmente como fixo/permanente no documento de design. +* **[Resolvido, revisto em 2026-10-02] §1.2 (Dark Mode):** A decisão de "fixo/permanente" foi superada: o app passou a oferecer Claro/Escuro/Automático (`45ae667`). O documento de design foi atualizado; o teste de regressão é `theme_controller_test.dart` mais as duas matrizes de contraste. * **[Resolvido] §1.3 (Foco Suave):** Inspeção visual validada: o anel M3 padrão apresenta constraste funcional sobre o `surfaceRaised` sem necessidade de overrides. * **[Resolvido] §1.4 (Microinterações):** *Ripple* formalizado como equivalência intencional ao `active:scale`. * **[Resolvido] §2.1 (Fricção de Emergência):** Limiar formalizado de "≤ 4 toques e ≤ 10s" no design. From 66bf765fe03f3bc7d54311404f1fe64412c7d0ce Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 10:16:22 -0400 Subject: [PATCH 28/43] =?UTF-8?q?docs:=20registra=20a=20revis=C3=A3o=20e?= =?UTF-8?q?=20o=20fechamento=20do=20app=20ACS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 1 + apps/CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/PROGRESS.md b/PROGRESS.md index a8daba1e..389146c7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -567,6 +567,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. - **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). - **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). +- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, MFA/refresh token, iOS, botão da UBS. - **Fora:** RF08 (cache persistido da microárea offline — exige desenho de LGPD antes), MFA/refresh token, iOS (`apps/acs/ios/` não existe), botão da UBS. - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 228fa64b..dd8b675f 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -46,7 +46,7 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. -**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. From b99303da8511d3d01318889dc4711564badf7db0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 11:17:35 -0400 Subject: [PATCH 29/43] =?UTF-8?q?docs:=20plano=20das=20pend=C3=AAncias=20d?= =?UTF-8?q?o=20ACS=20(FLAG=5FSECURE,=20assinatura,=20UBS,=20RF08,=20MFA,?= =?UTF-8?q?=20mTLS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...lag-secure-ubs-rf08-mfa-assinatura-mtls.md | 3080 +++++++++++++++++ 1 file changed, 3080 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md diff --git a/docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md b/docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md new file mode 100644 index 00000000..0dcd4d81 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md @@ -0,0 +1,3080 @@ +# Pendências do ACS: FLAG_SECURE, assinatura, botão da UBS, RF08, MFA e mTLS — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Goal:** Fechar as seis pendências que o `PROGRESS.md` deixou abertas no app do ACS e no broker: bloquear captura de tela, assinar o release com chave própria, ligar para a UBS, persistir a microárea offline (RF08), exigir MFA (TOTP) no login institucional e exigir certificado de cliente no broker (mTLS). + +**Architecture:** Seis frentes independentes, uma ou duas tarefas cada, na ordem do menor para o maior risco: (1) `FLAG_SECURE` na `MainActivity`; (2) `signingConfig` de release lido de `key.properties`/ambiente, com a build falhando sem chave; (3) contato da UBS como coluna nova em `ubs` mais um endpoint `ubs.myContact` e o botão passando a discar; (4) cache da microárea na mesma base SQLCipher, com dono, validade e apagamento; (5)(6) TOTP RFC 6238 no `loginInstitutional` (backend, depois app); (7)(8) o Mosquitto passa a exigir certificado de cliente, o backend e o ACS passam a apresentá-lo. Cada tarefa tem um teste que falha antes e termina com prova no `emulator-5554` quando há o que provar nele. + +**Tech Stack:** Kotlin (Android), Gradle Kotlin DSL, Flutter/Dart, Serverpod 3.4.13 (`serverpod generate`/`create-migration`), `sqflite_sqlcipher`, `crypto` (HMAC-SHA1), `qr_flutter`, Mosquitto 2 + OpenSSL, `integration_test`, `scripts/qa/*.sh`. + +**Spec:** `spec/PRD_system.md` (RF07, RF08, RF13, RNF04, RNF06), `spec/lgpd_design.md` (LGPD-RT06 §Sessões e autenticação, §5.1 chave local, §5.6 minimização), `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4, §5), `spec/lgpd_data_audit.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `PROGRESS.md` (itens "Continua aberto" de 2026-10-02). + +## Decisões tomadas neste plano + +Cada uma é reversível; a coluna "se errada" diz o custo. + +| # | Decisão | Se errada | +|---|---|---| +| D1 | `FLAG_SECURE` na **janela inteira** do ACS, não só na lista da microárea. A lista aparece em Área, Visita e no seletor; proteger tela por tela deixaria uma sem. Efeito colateral aceito: `adb screencap` e gravação de tela do ACS saem pretos. | Remover duas linhas da `MainActivity`. | +| D2 | Chave de release lida de `apps/acs/android/key.properties` (já no `.gitignore`) **ou** de variáveis `SINALACS_KEYSTORE_*`. Sem chave, `assembleRelease` **falha**; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. O plano **não cria** keystore real nem segredo de CI. | Apagar o guard. | +| D3 | O telefone da UBS é a coluna **nullable** `ubs.contactPhone`, entregue ao ACS por `ubs.myContact`. Sem telefone, o botão avisa e não liga. Quem cadastra o número é o backoffice, que ainda é mock: hoje vale a seed. | Coluna e endpoint são aditivos. | +| D4 | O cache de RF08 guarda **só o que a tela de visita já mostra** (nome, `isChronic`, condições) na base SQLCipher; validade de **72 h**; chaveado por `userId\|microAreaId`; apagado quando o dono muda ou a validade vence; **só entra no lugar de uma falha recuperável de rede**, nunca de uma recusa do servidor. | Mudar `maxAge`; ou apagar a tabela v6. | +| D5 | MFA = **TOTP RFC 6238** (SHA-1, 6 dígitos, 30 s, janela ±1). O segredo é cifrado com a mesma chave AES-256-GCM dos dados clínicos. A ativação **não usa token**: exige matrícula + senha + um código válido. `REQUIRE_ACS_MFA` é `false` em `development` e `true` fora dele. Sem MFA em produção, o ACS é obrigado a ativar antes de entrar. | Exigir só em produção já é o padrão; trocar o default. | +| D6 | Refresh token **continua fora** (a pendência era "MFA e refresh token"; o pedido foi só MFA). Redefinição de MFA por coordenador também fica fora: hoje se faz zerando as colunas no banco. | Plano à parte. | +| D7 | mTLS **mantém a senha MQTT**: `require_certificate true` sem `use_identity_as_username`. Trocar a senha pela identidade do certificado derrubaria o guard do Gradle, `BackendConfig.mqttPasswordMissing`, os scripts de dev e o CI. Fica cert **e** senha. | Passar a `use_identity_as_username` é plano à parte. | +| D8 | O certificado de cliente do ACS vem de **asset de desenvolvimento** (`assets/certs/acs_client.*`, gitignorado, copiado por `sync_dev_ca.sh`). A chave privada no APK serve só ao dev. Um release com ela dentro **falha** a build. O provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore) **não** entra: o mTLS fica entregue na stack local e **aberto** para produção. | Dizer isso no `PROGRESS.md`; não fingir fechamento. | + +## Estado verificado em 2026-10-02 + +Medido ou lido nesta sessão: + +- ACS: `flutter analyze` limpo, `flutter test` com **220** testes passando; `emulator-5554` no ar. +- `MainActivity.kt` do ACS é `class MainActivity : FlutterActivity()`, sem `FLAG_SECURE`. +- `build.gradle.kts`: `buildTypes.release` assina com `signingConfigs.getByName("debug")` (comentário `TODO`); `android/.gitignore` já ignora `key.properties`, `*.jks`, `*.keystore`. +- `EscalationScreen` (`app.dart` ~1936): "Encaminhar para UBS Central" só mostra `Encaminhamento será integrado à UBS.`; `ubs` não tem coluna de contato; o `Acs` tem `ubsId`. +- RF08: `patients.listMicroArea` é chamada **ao vivo** em dois pontos (`_loadMicroAreaPatients`, painel; `_loadPatients`, tela de visita). Falha vira `BackendFailure` com `isRecoverable`; recusa de sessão vem com `isRecoverable: false`. A base local é `sinalacs_acs.db`, `schemaVersion = 5`, com `offline_visits` e `sync_cursor`. +- MFA: `InstitutionalAuthService.login` (`institutional_auth_service.dart`) verifica Argon2id, bloqueia em 5 falhas por 15 min, devolve a mesma mensagem para matrícula inexistente e senha errada; `user_credentials` não tem coluna de MFA; `HealthDataCipher.encrypt/decrypt` cifram `String`. +- mTLS: `mosquitto.conf` tem `allow_anonymous false` + `password_file` + `acl_file`, **sem** `require_certificate`; `init.sh` gera só o certificado do servidor; o app (`mqtt_secure_client.dart:290`) e o backend (`mqtt_alert_dispatcher.dart:61`) só confiam na CA, não apresentam certificado. + +## Fora deste plano + +Refresh token; redefinição de MFA por coordenador; assinatura de release dos apps `patient` e `admin`; segredos de assinatura no CI; cadastro do telefone da UBS pelo backoffice; provisionamento de certificado por aparelho em produção; iOS (`apps/acs/ios/` não existe). + +## Global Constraints + +- Português em comentários, mensagens de commit e texto de UI, como o resto do repositório. +- Commits **sem** atribuição de IA: nenhuma linha "Co-Authored-By", nenhuma "Generated with Claude Code" (regra do `CLAUDE.md` do projeto, que prevalece sobre o lembrete de atribuição da sessão). Tudo na branch `fix/app_acs`, sem push, merge nem PR. +- Nada de dado real de paciente em teste, log ou captura; só fixtures sintéticas. Segredo nunca em `argv` de `docker`/`gradle` quando houver alternativa (variável de ambiente). +- Migração do banco do servidor: só aditiva (coluna nullable ou tabela nova). Depois de mexer em `.spy.yaml` ou endpoint: `serverpod generate` e `serverpod create-migration` em `backend/sinalacs_server`. +- Migração da base local do ACS: só aditiva (`CREATE TABLE IF NOT EXISTS`), e `EncryptedLocalDatabase.schemaVersion` sobe junto com `_upgrade`. +- Triagem determinística e a semântica de retry/conflito/rejeição de `OfflineVisitQueue` não mudam. RNF06: o território vem sempre do token. +- Os scripts `acs_full_e2e.sh` e `patient_full_e2e.sh` derrubam a stack de desenvolvimento: rodar `docker compose up -d` depois. Nenhum script apaga um app já instalado sem `--reinstalar`. +- Ambiente de todos os comandos: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin"`; raiz `/home/rock/Documents/Dev/APPs/SinalACS`. +- Cor clínica usada como texto passa pelo token `*OnSurface` (`apps/CLAUDE.md`); este plano não adiciona cor. + +## Review Focus + +1. **Código TOTP reaproveitado dentro da mesma janela de 30 s (replay):** deve ser recusado. → Task 5, teste `recusa o mesmo código usado duas vezes`. +2. **Código TOTP errado depois de senha certa:** conta como tentativa, tranca no 5º erro como a senha, e **não** consome o passo. → Task 5, testes `código errado conta tentativa e tranca` e `código errado não consome o passo`. +3. **Cache da microárea servido a outro usuário/outra microárea, ou depois de 72 h:** nunca. → Task 4, testes `outro dono apaga e não serve` e `vencido não serve e é apagado`; e recusa do servidor nunca cai no cache. +4. **Broker aceitando cliente sem certificado ou com certificado de outra CA:** deve recusar, e a senha continua exigida. → Task 7, script `mtls_invariants.sh` (quatro casos). +5. **Release saindo com chave de debug ou com a chave privada de desenvolvimento dentro:** a build deve falhar. → Task 2 (sem chave) e Task 8 (chave de dev no asset). + +--- + +### Task 1: Janela do ACS com `FLAG_SECURE` + +**Files:** +- Modify: `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt` +- Create: `apps/acs/test/secure_window_test.dart` +- Create: `scripts/qa/acs_secure_window.sh` +- Modify: `spec/lgpd_design.md` (fim da §5.1), `apps/CLAUDE.md` + +**Interfaces:** Consumes: —. Produces: nada que outra task use. + +- [ ] **Step 1: Escrever o teste de fonte que falha** + +Criar `apps/acs/test/secure_window_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A lista da microárea mostra nome e condições crônicas (LGPD §5.6). Sem +/// `FLAG_SECURE`, o sistema deixa a tela ser capturada, gravada e reduzida a +/// miniatura nos "apps recentes" — dado de saúde fora do controle do app. +/// Este teste lê a `MainActivity` porque `flutter test` não tem janela +/// Android; a prova na janela de verdade é `scripts/qa/acs_secure_window.sh`. +void main() { + final fonte = File( + 'android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt', + ).readAsStringSync(); + + test('a janela do ACS é FLAG_SECURE (sem captura, gravação nem miniatura)', () { + expect(fonte, contains('override fun onCreate')); + expect(fonte, contains('WindowManager.LayoutParams.FLAG_SECURE')); + expect(fonte, contains('window.setFlags(')); + }); +} +``` + +- [ ] **Step 2: Escrever o verificador no emulador** + +Criar `scripts/qa/acs_secure_window.sh` e `chmod +x`: + +```bash +#!/usr/bin/env bash +# +# A janela do app do ACS tem FLAG_SECURE no emulador-5554 (sem captura de tela, +# gravação nem miniatura nos recentes). A prova é a flag da janela segundo o +# WindowManager (`dumpsys window windows`, linha `fl=`), não a leitura do código. +# +# ./scripts/qa/acs_secure_window.sh [--reinstalar] +# +# Aborta (exit 3) se o app já está instalado: reinstalar APAGA a fila de visitas +# offline (SQLCipher) e a chave do Keystore do app de desenvolvimento. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +reinstalar=0 +for arg in "$@"; do + case "$arg" in + --reinstalar) reinstalar=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +instalado() { adb -s "$dev" shell pm list packages "$pkg" 2>/dev/null | grep -q "^package:$pkg\$"; } +if instalado && [[ "$reinstalar" -eq 0 ]]; then + echo "erro: $pkg já está instalado em $dev; reinstalar APAGA a fila offline e a chave do Keystore." >&2 + echo " Se for só o app de teste de uma execução anterior, rode com --reinstalar." >&2 + exit 3 +fi + +log="$(mktemp)" +instalamos=0 +limpar() { + rm -f "$log" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT + +if ! (cd apps/acs && flutter build apk --debug \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" >"$log" 2>&1); then + echo "erro: o build do APK falhou. Últimas linhas:" >&2 + tail -n 30 "$log" >&2 + exit 1 +fi +if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +adb -s "$dev" install -r apps/acs/build/app/outputs/flutter-apk/app-debug.apk >/dev/null +instalamos=1 +adb -s "$dev" shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 +sleep 6 + +# Primeiro `fl=` do bloco da janela do app (a linha `pfl=` é de outra coisa). +flags="$(adb -s "$dev" shell dumpsys window windows | awk -v pkg="$pkg" ' + /^ Window #/ { dentro = index($0, pkg "/") > 0 } + dentro && /^ +fl=/ { print; exit }')" +if [[ -z "$flags" ]]; then + echo "erro: a janela de $pkg não apareceu em dumpsys window (o app abriu?)." >&2 + exit 4 +fi +if ! grep -qw SECURE <<<"$flags"; then + echo "erro: a janela do ACS não tem FLAG_SECURE: $flags" >&2 + exit 1 +fi +echo "OK — janela do ACS com FLAG_SECURE" +``` + +- [ ] **Step 3: Ver os dois falharem** + +Run: `cd apps/acs && flutter test test/secure_window_test.dart 2>&1 | tail -6; cd ../.. && ./scripts/qa/acs_secure_window.sh; echo rc=$?` +Expected: o teste **FALHA** (`Expected: contains 'override fun onCreate'`); o script imprime `erro: a janela do ACS não tem FLAG_SECURE: fl=...` e `rc=1`. Se o script der `rc=4` (janela não apareceu), o `awk` não casou com o formato do `dumpsys` deste Android: rode `adb shell dumpsys window windows | grep -n "Window #\|fl="` com o app aberto e ajuste **só** a expressão do `awk` até achar o `fl=` do app **sem** SECURE (é o vermelho certo). Não siga com `rc=4`. + +- [ ] **Step 4: Implementar** + +Substituir o conteúdo de `MainActivity.kt`: + +```kotlin +package br.com.prismrr.sinalacs.acs + +import android.os.Bundle +import android.view.WindowManager +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // A lista da microárea mostra nome e condições crônicas. FLAG_SECURE + // bloqueia captura de tela, gravação e a miniatura nos apps recentes, + // na janela INTEIRA: Área, Visita e o seletor de paciente a exibem, e + // proteger tela por tela deixaria uma de fora. + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } +} +``` + +- [ ] **Step 5: Ver os dois passarem** + +Run: `cd apps/acs && flutter test test/secure_window_test.dart 2>&1 | tail -2; cd ../.. && ./scripts/qa/acs_secure_window.sh; echo rc=$?` +Expected: `All tests passed!`; `OK — janela do ACS com FLAG_SECURE` e `rc=0`; depois, `adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs` não lista nada (o script remove o app). + +- [ ] **Step 6: Conferir que nada que já rodava no emulador depende de captura** + +Run: `grep -rn "screencap\|takeScreenshot\|--screenshot" scripts apps/acs/integration_test apps/acs/test_driver 2>/dev/null | head; ./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao` +Expected: o `grep` não acha nada; os dois cenários de GPS terminam em `OK` (o `uiautomator dump` do tocador continua funcionando; ele lê a árvore de acessibilidade, não a imagem). + +- [ ] **Step 7: Documentar e commitar** + +Em `spec/lgpd_design.md`, no fim da §5.1 (onde está a decisão da chave local do SQLCipher), acrescentar: "**Captura de tela (2026-10-02).** A janela do app do ACS é `FLAG_SECURE`: o sistema não permite captura de tela, gravação nem miniatura nos apps recentes. Decisão: a janela inteira, porque a lista da microárea (nome e condições crônicas) aparece em mais de uma tela. Consequência aceita: `adb screencap` do ACS sai preto. Provado por `scripts/qa/acs_secure_window.sh`." + +Em `apps/CLAUDE.md`, no parágrafo "ACS: permissões, SAMU e e2e", acrescentar: "A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código." + +```bash +git add apps/acs/android/app/src/main/kotlin apps/acs/test/secure_window_test.dart scripts/qa/acs_secure_window.sh spec/lgpd_design.md apps/CLAUDE.md +git commit -m "feat(acs): janela com FLAG_SECURE bloqueia captura de tela, gravação e miniatura dos recentes" +``` + +--- + +### Task 2: Assinatura de release com chave própria + +**Files:** +- Modify: `apps/acs/android/app/build.gradle.kts` +- Create: `scripts/qa/acs_release_signing.sh` +- Modify: `apps/CLAUDE.md` + +**Interfaces:** Consumes: —. Produces: propriedades Gradle `sinalacs.allowDebugSigning` (usada nesta task e na Task 8) e as variáveis `SINALACS_KEYSTORE_PATH`, `SINALACS_KEYSTORE_PASSWORD`, `SINALACS_KEY_ALIAS`, `SINALACS_KEY_PASSWORD`. + +- [ ] **Step 1: Escrever o verificador (falha porque hoje a build assina com debug)** + +Criar `scripts/qa/acs_release_signing.sh` e `chmod +x`: + +```bash +#!/usr/bin/env bash +# +# A build de release do ACS não sai com a chave de debug sem pedir. +# +# ./scripts/qa/acs_release_signing.sh +# +# Três cenários, cada um uma build de release (~1 min): +# 1. sem chave de release e sem a licença de debug -> a build FALHA, dizendo por quê; +# 2. com -Psinalacs.allowDebugSigning=true -> assina com "Android Debug"; +# 3. com um keystore descartável (criado aqui, em /tmp) por variáveis de ambiente +# -> assina com o CN do keystore. +# Nenhum segredo real é tocado: o keystore do cenário 3 nasce e morre neste script. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +if [[ -f apps/acs/android/key.properties ]]; then + echo "erro: apps/acs/android/key.properties existe; ele mudaria o cenário 1. Mova-o para fora e rode de novo." >&2 + exit 3 +fi +apksigner="$(ls "$HOME"/Android/Sdk/build-tools/*/apksigner | tail -1)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +apk=apps/acs/build/app/outputs/flutter-apk/app-release.apk +define="--dart-define=SINALACS_MQTT_PASSWORD=$MQTT_ACS_PASSWORD" + +# O Gradle lê as variáveis SINALACS_KEYSTORE_* do ambiente; nos cenários 1 e 2 elas não existem. +construir() { (cd apps/acs && flutter build apk --release "$define" "$@"); } + +echo "== cenário 1: sem chave de release ==" +if saida="$(env -u SINALACS_KEYSTORE_PATH construir 2>&1)"; then + echo "erro: a build de release passou sem chave de release." >&2 + exit 1 +fi +grep -q "assinatura de release" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } + +echo "== cenário 2: -Psinalacs.allowDebugSigning=true ==" +construir -Psinalacs.allowDebugSigning=true >/dev/null +"$apksigner" verify --print-certs "$apk" | grep -q "CN=Android Debug" \ + || { echo "erro: o cenário 2 não assinou com a chave de debug." >&2; exit 1; } + +echo "== cenário 3: keystore descartável por variáveis de ambiente ==" +keytool -genkeypair -keystore "$tmp/teste.jks" -storepass senhateste -keypass senhateste \ + -alias teste -keyalg RSA -keysize 2048 -validity 2 -dname "CN=SinalACS Teste" >/dev/null 2>&1 +SINALACS_KEYSTORE_PATH="$tmp/teste.jks" SINALACS_KEYSTORE_PASSWORD=senhateste \ +SINALACS_KEY_ALIAS=teste SINALACS_KEY_PASSWORD=senhateste construir >/dev/null +"$apksigner" verify --print-certs "$apk" | grep -q "CN=SinalACS Teste" \ + || { echo "erro: o cenário 3 não assinou com o keystore informado." >&2; exit 1; } + +echo "OK — release exige chave própria; debug só com licença explícita" +``` + +- [ ] **Step 2: Ver falhar** + +Run: `./scripts/qa/acs_release_signing.sh; echo rc=$?` +Expected: `erro: a build de release passou sem chave de release.` e `rc=1` (o cenário 1 já falha o script: hoje a build assina com debug e passa). + +- [ ] **Step 3: Implementar no Gradle** + +Em `apps/acs/android/app/build.gradle.kts`: acrescentar, junto do `import java.util.Base64` do topo, + +```kotlin +import java.io.FileInputStream +import java.util.Properties +``` + +Depois da função `dartDefineValue` e antes do bloco `plugins`, acrescentar: + +```kotlin +// Chave de release: de `apps/acs/android/key.properties` (gitignorado) ou, para +// CI e shell, das variáveis SINALACS_KEYSTORE_*. Nenhum valor mora no repositório. +// storeFile=/caminho/para/release.jks +// storePassword=... +// keyAlias=... +// keyPassword=... +val keyProperties = Properties().apply { + val arquivo = rootProject.file("key.properties") + if (arquivo.exists()) FileInputStream(arquivo).use { load(it) } +} + +fun assinatura(propriedade: String, ambiente: String): String? = + keyProperties.getProperty(propriedade)?.takeIf { it.isNotBlank() } + ?: System.getenv(ambiente)?.takeIf { it.isNotBlank() } + +val releaseStoreFile = assinatura("storeFile", "SINALACS_KEYSTORE_PATH") +val releaseStorePassword = assinatura("storePassword", "SINALACS_KEYSTORE_PASSWORD") +val releaseKeyAlias = assinatura("keyAlias", "SINALACS_KEY_ALIAS") +val releaseKeyPassword = assinatura("keyPassword", "SINALACS_KEY_PASSWORD") +val temChaveDeRelease = listOf( + releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword, +).all { it != null } +``` + +Dentro de `android { ... }`, antes de `buildTypes`, acrescentar e trocar o bloco `release`: + +```kotlin + signingConfigs { + if (temChaveDeRelease) { + create("release") { + storeFile = file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + + buildTypes { + release { + // Sem chave, a configuração continua carregável (sync da IDE, debug): + // quem barra a build de RELEASE é o guard `taskGraph.whenReady` abaixo. + signingConfig = signingConfigs.getByName(if (temChaveDeRelease) "release" else "debug") + } + } +``` + +No fim do arquivo, acrescentar o guard: + +```kotlin +// Guard de release: só quando uma tarefa de release está no grafo (o sync da IDE +// e `flutter run` em debug não passam por aqui). Sem chave de release, assinar +// com a de debug exige pedir (-Psinalacs.allowDebugSigning=true). +gradle.taskGraph.whenReady { + val buildaRelease = allTasks.any { + it.path.endsWith(":assembleRelease") || + it.path.endsWith(":bundleRelease") || + it.path.endsWith(":packageRelease") + } + if (buildaRelease && !temChaveDeRelease && !project.hasProperty("sinalacs.allowDebugSigning")) { + throw GradleException( + """ + |Sem chave de assinatura de release: esta build sairia assinada com a chave de debug. + | + |Informe a chave por apps/acs/android/key.properties (storeFile, storePassword, + |keyAlias, keyPassword) ou pelas variáveis SINALACS_KEYSTORE_PATH, + |SINALACS_KEYSTORE_PASSWORD, SINALACS_KEY_ALIAS e SINALACS_KEY_PASSWORD. + | + |Para assinar com a chave de debug DE PROPÓSITO (teste local, nunca distribuição): + | flutter build apk --release -Psinalacs.allowDebugSigning=true + """.trimMargin() + ) + } +} +``` + +- [ ] **Step 4: Ver passar** + +Run: `./scripts/qa/acs_release_signing.sh; echo rc=$?` +Expected: três cabeçalhos `== cenário N ==` e `OK — release exige chave própria; debug só com licença explícita`, `rc=0`. Se o cenário 1 falhar por o `grep "assinatura de release"` não casar, a mensagem do `GradleException` está certa e o Flutter a reformatou: ajuste o `grep` para uma palavra que apareça na saída real (`Sem chave de assinatura`) e **não** a mensagem. Se o `-P` do cenário 2 não chegar ao Gradle, use `--android-project-arg=sinalacs.allowDebugSigning=true` (flag do Flutter para argumentos do Gradle) nos scripts **e** na documentação. + +- [ ] **Step 5: Documentar e commitar** + +Em `apps/CLAUDE.md`, no parágrafo "ACS: permissões, SAMU e e2e", acrescentar: "A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto)." + +```bash +git add apps/acs/android/app/build.gradle.kts scripts/qa/acs_release_signing.sh apps/CLAUDE.md +git commit -m "feat(acs): build de release exige chave própria; debug só com licença explícita" +``` + +--- + +### Task 3: Botão da UBS passa a discar (RF13) + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/ubs.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart` +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart` +- Create: `backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart` +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (fábrica `ubsContactServiceFor`) +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql`, `backend/sinalacs_server/bin/seed_e2e_fixtures.dart` +- Test: `backend/sinalacs_server/test/unit/ubs_contact_service_test.dart` +- Modify: `apps/acs/lib/core/network/backend_client.dart`, `apps/acs/lib/app/app.dart`, `apps/acs/test/support/fakes.dart` +- Test: `apps/acs/test/escalation_ubs_test.dart` +- Modify: `spec/lgpd_data_audit.md`, `apps/CLAUDE.md` + +**Interfaces:** +- Consumes: `Authorization.require(user, roles:, onDenied:)` (usado por `VisitSyncService`), `AuthenticatedEndpoint.authenticate(accessToken)`, `EmergencyDialer.dial(String) -> Future`, `BackendScope.of(context)`. +- Produces: protocolo `UbsContact{ String name; String? phone }`; RPC `ubs.myContact(accessToken:)`; `AcsBackend.ubsContact() -> Future`; `UbsContactService.contactFor(AuthenticatedUser) -> Future`. + +- [ ] **Step 1: Teste do serviço (falha: o serviço não existe)** + +Criar `backend/sinalacs_server/test/unit/ubs_contact_service_test.dart`: + +```dart +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; + +class _FakeStore implements UbsContactStore { + _FakeStore(this.record); + UbsContactRecord? record; + final consultados = []; + + @override + Future findForAcs(String acsId) async { + consultados.add(acsId); + return record; + } +} + +AuthenticatedUser _usuario(UserRole role) => AuthenticatedUser( + id: _acsId, + role: role, + microAreaId: '00000000-0000-4000-8000-000000000003', + deviceId: 'dispositivo-teste', + ); + +void main() { + test('devolve nome e telefone da UBS do ACS', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS Teste', phone: '+55 11 5550-0100')), + ); + + final contato = await service.contactFor(_usuario(UserRole.acs)); + + expect(contato.name, 'UBS Teste'); + expect(contato.phone, '+55 11 5550-0100'); + }); + + test('UBS sem telefone cadastrado devolve phone nulo, sem falhar', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS Sem Fone', phone: null)), + ); + final contato = await service.contactFor(_usuario(UserRole.acs)); + expect(contato.phone, isNull); + }); + + test('telefone só com espaços vale como não cadastrado', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS', phone: ' ')), + ); + expect((await service.contactFor(_usuario(UserRole.acs))).phone, isNull); + }); + + test('paciente não consulta o contato (nem toca no banco)', () async { + final store = _FakeStore(const UbsContactRecord(name: 'UBS', phone: '1')); + final service = UbsContactService(store: store); + + expect(() => service.contactFor(_usuario(UserRole.patient)), throwsA(isA())); + expect(store.consultados, isEmpty); + }); + + test('ACS sem linha na tabela acs é erro, não contato vazio', () async { + final service = UbsContactService(store: _FakeStore(null)); + expect(() => service.contactFor(_usuario(UserRole.acs)), throwsA(isA())); + }); +} +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/ubs_contact_service_test.dart 2>&1 | tail -6` +Expected: **FAIL** de compilação (`Target of URI doesn't exist: ...ubs_contact_service.dart`). Antes disso, anotar a linha de base: `dart test test/unit 2>&1 | tail -2` (o total de testes unitários) no ledger. + +- [ ] **Step 3: Modelos, serviço, store e endpoint** + +`ubs.spy.yaml`, acrescentar ao final de `fields:`: + +```yaml + ### Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + ### unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + contactPhone: String? +``` + +Criar `models/api/ubs_contact.spy.yaml`: + +```yaml +### Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +### precisa: o nome para a mensagem e o telefone para o discador. +class: UbsContact +fields: + name: String + phone: String? +``` + +Criar `application/ubs/ubs_contact_service.dart`: + +```dart +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class UbsContactRecord { + const UbsContactRecord({required this.name, required this.phone}); + + final String name; + final String? phone; +} + +abstract interface class UbsContactStore { + /// UBS do ACS `acsId`, ou `null` se o ACS ou a UBS não existirem. + Future findForAcs(String acsId); +} + +/// Contato da UBS para o botão de escalonamento do ACS (RF13). +/// +/// A UBS vem do **token** (`user.id` é o id do ACS), nunca de parâmetro — mesma +/// regra de território das outras consultas do ACS. +class UbsContactService { + UbsContactService({required this.store}); + + final UbsContactStore store; + + Future contactFor(AuthenticatedUser user) async { + Authorization.require( + user, + roles: {UserRole.acs}, + onDenied: () => StateError('Somente ACS consultam o contato da UBS.'), + ); + + final record = await store.findForAcs(user.id); + if (record == null) { + throw StateError('UBS do ACS não encontrada.'); + } + final phone = record.phone?.trim(); + return UbsContact( + name: record.name, + phone: (phone == null || phone.isEmpty) ? null : phone, + ); + } +} +``` + +Se `Authorization` não estiver em `application/auth/authorization.dart` ou exigir `microAreaId`, copie o formato exato da chamada em `application/visits/*` (o `VisitSyncService.pull` usa `Authorization.require(user, roles: {UserRole.acs}, onDenied: ...)`) e ajuste o `import`. + +Criar `infrastructure/database/orm_ubs_contact_store.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class OrmUbsContactStore implements UbsContactStore { + OrmUbsContactStore(this._session); + + final Session _session; + + @override + Future findForAcs(String acsId) async { + final acs = await Acs.db.findById(_session, UuidValue.fromString(acsId)); + if (acs == null) return null; + final ubs = await Ubs.db.findById(_session, acs.ubsId); + if (ubs == null) return null; + return UbsContactRecord(name: ubs.name, phone: ubs.contactPhone); + } +} +``` + +Criar `endpoints/ubs_endpoint.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Contato da UBS do ACS (RF13). +class UbsEndpoint extends AuthenticatedEndpoint { + Future myContact( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + try { + return await AlertRuntime.instance.ubsContactServiceFor(session).contactFor(user); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} +``` + +Em `runtime/alert_runtime.dart`, ao lado de `patientDirectoryServiceFor` (linha ~206), acrescentar (com os `import`s do serviço e do store): + +```dart + /// Contato da UBS do ACS (RF13), para uma requisição. + UbsContactService ubsContactServiceFor(Session session) => + UbsContactService(store: OrmUbsContactStore(session)); +``` + +- [ ] **Step 4: Gerar, migrar e ver o serviço passar** + +Run: `cd backend/sinalacs_server && serverpod generate 2>&1 | tail -3 && serverpod create-migration 2>&1 | tail -3 && dart analyze 2>&1 | tail -2 && dart test test/unit/ubs_contact_service_test.dart 2>&1 | tail -2` +Expected: `generate` e `create-migration` sem erro (uma pasta nova em `migrations/` com `ALTER TABLE "ubs" ADD COLUMN "contactPhone" text;`); `No issues found!`; `All tests passed!` (5 testes). Conferir no SQL gerado que a coluna é **nullable** e que nada é `DROP`. + +- [ ] **Step 5: Seed de desenvolvimento e de e2e** + +Em `development.sql`, **depois** do `INSERT INTO "ubs" ... ON CONFLICT ("id") DO NOTHING;`, acrescentar (o `DO NOTHING` não atualiza bancos que já têm a UBS): + +```sql +-- Telefone sintético (RF13). O UPDATE cobre bancos de dev criados antes da coluna. +UPDATE "ubs" SET "contactPhone" = '+55 11 5550-0100' +WHERE "id" = '00000000-0000-4000-8000-000000000004' AND "contactPhone" IS NULL; +``` + +Em `bin/seed_e2e_fixtures.dart` (~linha 52), trocar o `INSERT INTO "ubs"` por: + +```dart + Sql.named('INSERT INTO "ubs" ("id","name","address","city","state","contactPhone") ' + "VALUES (@id, 'UBS E2E', 'Endereço de teste', 'São Paulo', 'SP', '+55 11 5550-0199')"), +``` + +- [ ] **Step 6: Teste do app (falha: não há `ubsContact`)** + +Em `apps/acs/test/support/fakes.dart`, dentro de `FakeAcsBackend`, acrescentar: + +```dart + /// Contato que `ubsContact()` devolve; `null` simula uma UBS sem telefone. + UbsContact ubsContactResult = UbsContact(name: 'UBS Teste', phone: '+55 11 5550-0100'); + + /// Falha da chamada, como uma queda de rede. + BackendFailure? ubsContactFailure; + + int ubsContactCount = 0; + + @override + Future ubsContact() async { + ubsContactCount++; + final falha = ubsContactFailure; + if (falha != null) throw falha; + return ubsContactResult; + } +``` + +Criar `apps/acs/test/escalation_ubs_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show UbsContact; + +import 'support/fakes.dart'; + +class _Dialer implements EmergencyDialer { + _Dialer({this.abre = true}); + final bool abre; + final discados = []; + + @override + Future dial(String number) async { + discados.add(number); + return abre; + } +} + +Future _abrir(WidgetTester tester, FakeAcsBackend backend, _Dialer dialer) => + tester.pumpWidget(MaterialApp( + home: BackendScope( + backend: backend, + child: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + ), + )); + +void main() { + testWidgets('o botão da UBS abre o discador com o telefone cadastrado, só dígitos e +', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, ['+551155500100']); + expect(backend.ubsContactCount, 1); + }); + + testWidgets('UBS sem telefone: avisa e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactResult = UbsContact(name: 'UBS Sem Fone'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.textContaining('UBS Sem Fone ainda não cadastrou um telefone'), findsOneWidget); + }); + + testWidgets('sem discador, mostra o telefone em texto', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(abre: false); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Ligue manualmente para +55 11 5550-0100'), findsOneWidget); + }); + + testWidgets('falha ao obter o contato mostra a mensagem do backend e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactFailure = const BackendFailure('Sem conexão.'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.text('Sem conexão.'), findsOneWidget); + }); +} +``` + +- [ ] **Step 7: Ver falhar** + +Run: `cd apps/acs && flutter test test/escalation_ubs_test.dart 2>&1 | tail -6` +Expected: **FAIL** de compilação (`The method 'ubsContact' isn't defined` / `Key('escalation_ubs')` ausente). Se `UbsContact` não for exportado por `sinalacs_client`, rode `cd backend && dart pub get` e confira que o `serverpod generate` do Step 4 regenerou `backend/sinalacs_client`. + +- [ ] **Step 8: Implementar no app** + +Em `backend_client.dart`: na interface `AcsBackend` acrescentar + +```dart + /// Contato da UBS do ACS (RF13): nome e telefone, que pode não estar cadastrado. + Future ubsContact(); +``` + +na classe que recusa tudo (a que tem `_recusar()`): + +```dart + @override + Future ubsContact() async => _recusar(); +``` + +e no `BackendClient`, ao lado de `listPatients`: + +```dart + @override + Future ubsContact() async { + final token = await _requireToken(); + return _guard(() => _client.ubs.myContact(accessToken: token)); + } +``` + +Em `app.dart`, em `_EscalationScreenState`, depois de `_callSamu`: + +```dart + /// Liga para a UBS do ACS (RF13). O telefone vem do servidor; sem ele, avisa. + Future _callUbs() async { + if (_dialing) return; + setState(() => _dialing = true); + String? aviso; + try { + final contato = await BackendScope.of(context).ubsContact(); + final telefone = contato.phone?.trim(); + if (telefone == null || telefone.isEmpty) { + aviso = '${contato.name} ainda não cadastrou um telefone. Acione a coordenação.'; + } else { + // O discador recebe só dígitos e `+`: espaço e hífen viram %20 no `tel:`. + final numero = telefone.replaceAll(RegExp(r'[^0-9+]'), ''); + if (!await widget.dialer.dial(numero)) { + aviso = 'Não foi possível abrir o discador. Ligue manualmente para $telefone.'; + } + } + } on BackendFailure catch (falha) { + aviso = falha.message; + } catch (_) { + aviso = 'Não foi possível obter o contato da UBS.'; + } + if (!mounted) return; + setState(() => _dialing = false); + if (aviso != null) _message(context, aviso); + } +``` + +e trocar o `OutlinedButton` "Encaminhar para UBS Central" por: + +```dart + OutlinedButton.icon( + key: const Key('escalation_ubs'), + onPressed: _dialing ? null : _callUbs, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.local_hospital_outlined), + label: const Text('Ligar para a UBS'), + ), +``` + +Procurar referências ao texto antigo: `grep -rn "Encaminhar para UBS\|será integrado à UBS" apps spec docs PROGRESS.md --include=*.dart --include=*.md`. Atualizar os testes que o citam (`escalation_dialer_test.dart` etc.) para o novo comportamento, e os `.md` que dizem "botão da UBS segue sendo um aviso" (`PROGRESS.md`, `apps/CLAUDE.md`, `spec/validation_report.md` linha RF13) para "o botão liga para a UBS (`ubs.myContact`); sem telefone cadastrado, avisa". + +- [ ] **Step 9: Ver passar e rodar tudo** + +Run: `cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test 2>&1 | tail -2` +Expected: `No issues found!`; `All tests passed!` com **≥ 224** testes (220 + 4). + +- [ ] **Step 10: Prova no emulador contra o servidor real** + +Em `apps/acs/integration_test/full_journey_e2e.dart`, dentro do **primeiro** `testWidgets` (depois do `pullVisits`, onde já existe `acsClient` logado), acrescentar: + +```dart + // RF13: o contato da UBS chega pelo servidor real, escopado pelo token do ACS. + final contato = await acsClient.ubs.myContact(accessToken: acs.accessToken); + expect(contato.name, 'UBS E2E'); + expect(contato.phone, '+55 11 5550-0199'); +``` + +Run: `docker compose up -d --build 2>&1 | tail -2; ./scripts/qa/acs_full_e2e.sh 2>&1 | grep -E "OK —|Some tests|Expected|Actual|rc=" ; docker compose up -d` +Expected: `OK — jornada completa do ACS contra o banco de teste`. (O `acs_full_e2e.sh` sobe a stack de e2e a partir do código atual; se ele não reconstruir a imagem do servidor, o `myContact` não existirá e o teste falha com `404`: rode `docker compose -f docker-compose.e2e.yml build` antes e registre no ledger.) + +- [ ] **Step 11: Documentar e commitar** + +Em `spec/lgpd_data_audit.md`, na tabela de `ubs`, acrescentar a linha `contactPhone | String? | Dado da unidade (não pessoal) | Telefone para escalonamento (RF13) | Nullable`; e **re-medir** a contagem de tabelas citada no `CLAUDE.md` da raiz (`definition.sql` da última migração) — a tabela não muda de número, só ganha coluna. + +```bash +git add backend apps/acs spec PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): botão da UBS liga para o telefone cadastrado (RF13) via ubs.myContact" +``` + +--- + +### Task 4: RF08 — microárea persistida e criptografada no aparelho + +**Files:** +- Modify: `spec/lgpd_design.md` (nova seção), `spec/lgpd_data_audit.md` +- Modify: `apps/acs/lib/core/database/encrypted_database.dart` (v6) +- Create: `apps/acs/lib/core/database/micro_area_cache_store.dart` +- Create: `apps/acs/lib/core/services/micro_area_directory.dart` +- Create: `apps/acs/lib/core/services/micro_area_directory_factory.dart` +- Modify: `apps/acs/lib/app/app.dart` (montagem e dois pontos de chamada, aviso de cache) +- Test: `apps/acs/test/micro_area_cache_store_test.dart`, `apps/acs/test/micro_area_directory_test.dart`, `apps/acs/test/encrypted_database_test.dart` (migração v6) +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (prova na base criptografada do aparelho) + +**Interfaces:** +- Consumes: `MicroAreaPatient({patientId, name, isChronic, chronicConditions})` (de `sinalacs_client`), `AuthSession{userId, microAreaId}` (`auth_session.dart`), `BackendFailure{message, isRecoverable}`, `DatabaseKeyStore.readOrCreate()`, `EncryptedLocalDatabase.open({databaseName, passphrase, allowUnencryptedForTesting})`. +- Produces: + - `class CachedMicroArea { final List patients; final DateTime fetchedAt; }` + - `class MicroAreaCacheStore { Future read({required String owner}); Future write({required String owner, required List patients, required DateTime at}); Future clear(); }` + - `class MicroAreaSnapshot { final List patients; final DateTime fetchedAt; final bool fromCache; }` + - `class MicroAreaDirectory { MicroAreaDirectory({required Future> Function() fetch, required AuthSession? Function() session, required MicroAreaCacheStore store, DateTime Function()? clock, Duration maxAge = const Duration(hours: 72)}); Future load(); }` + - `MicroAreaDirectory buildMicroAreaDirectory({required AcsBackend backend})` e a constante `microAreaCacheMaxAge`. + +- [ ] **Step 1: Escrever a decisão de LGPD antes do código** + +Em `spec/lgpd_design.md`, depois da §5.6 (minimização), acrescentar a seção: + +```markdown +### 5.7 Cache local da microárea (RF08) + +| | | +|---|---| +| **Decisão** | O ACS guarda no aparelho a última lista da microárea (`patients.listMicroArea`) para registrar visita **sem rede**. | +| **O que guarda** | Só o que a tela de visita já mostra: `patientId`, nome, `isChronic` e condições crônicas — o mesmo conjunto da §5.6. Nada de contato de emergência, endereço, histórico ou triagem. | +| **Onde** | Tabelas `micro_area_cache` e `micro_area_cache_meta` na **mesma base SQLCipher** das visitas offline (INV-04; chave no Keystore, §5.1). | +| **Dono** | A lista pertence a `userId\|microAreaId` da sessão que a baixou. Outro usuário ou outra microárea **apaga** o cache e não o serve (RNF06). | +| **Validade** | 72 horas desde o último download bem-sucedido. Vencida, **não é usada e é apagada**. | +| **Quando entra** | Só no lugar de uma falha **recuperável** de rede. Uma recusa do servidor (sessão inválida, território negado, `isRecoverable: false`) nunca cai no cache. | +| **Transparência** | A tela diz que a lista é do cache e de quando. | +| **Retenção** | Some pela validade, pelo troca de dono e com a desinstalação; nunca é enviada a lugar nenhum. | +| **Se for revisto** | Reduzir `microAreaCacheMaxAge` ou apagar a tabela v6 é migração aditiva reversível. | +``` + +Em `spec/lgpd_data_audit.md`, acrescentar as duas tabelas **locais** do aparelho (`micro_area_cache`: `patient_id`, `name` = identificador pessoal, `is_chronic`, `chronic_conditions` = dado sensível de saúde; `micro_area_cache_meta`: `owner`, `fetched_at` = metadado) com a mesma classificação das colunas equivalentes de `patients`, e a nota "no aparelho, sob SQLCipher". + +- [ ] **Step 2: Teste da migração v6 (falha: `schemaVersion` é 5)** + +Em `apps/acs/test/encrypted_database_test.dart`, copiando o `setUp`/`tearDown` e o jeito de criar um banco antigo dos testes de migração que já existem nesse arquivo (o de v4 → v5 para `sync_cursor`), acrescentar o caso: criar um banco **v5** com uma visita pendente em `offline_visits`, abri-lo com `EncryptedLocalDatabase.open`, e afirmar que (a) as duas tabelas novas existem (`SELECT name FROM sqlite_master WHERE name IN ('micro_area_cache','micro_area_cache_meta')` devolve 2 linhas), (b) a visita pendente continua lá, (c) `EncryptedLocalDatabase.schemaVersion == 6`. + +Run: `cd apps/acs && flutter test test/encrypted_database_test.dart 2>&1 | tail -6` +Expected: o caso novo **FALHA** (`Expected: <6> Actual: <5>`). + +- [ ] **Step 3: Implementar a migração v6** + +Em `encrypted_database.dart`: `static const schemaVersion = 6;`, acrescentar à doc da versão "v6 acrescenta `micro_area_cache` e `micro_area_cache_meta`, o cache da microárea (RF08, `spec/lgpd_design.md` §5.7)", e: + +```dart + /// Cache da microárea (RF08, §5.7 de spec/lgpd_design.md): a última lista que + /// o servidor devolveu, de um dono (`userId|microAreaId`), com a data do + /// download. Sob a mesma criptografia das visitas. + static const createMicroAreaCache = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache ( + patient_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_chronic INTEGER NOT NULL, + chronic_conditions TEXT NOT NULL +)'''; + + static const createMicroAreaCacheMeta = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +)'''; +``` + +Nos dois caminhos que criam o banco do zero (`onCreate`, linhas ~156 e ~171 — onde já se executam `createOfflineVisits` e `createSyncCursor`), executar também as duas novas; em `_upgrade`, depois do bloco `from < 5`: + +```dart + if (from < 6) { + // Aditiva: `CREATE TABLE IF NOT EXISTS` não toca em nada que já existe. + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); + } +``` + +Atualizar o comentário "Migração v1 → v2, … e v4 → v5" para incluir v5 → v6. + +Run: `cd apps/acs && flutter test test/encrypted_database_test.dart 2>&1 | tail -3` +Expected: `All tests passed!`. + +- [ ] **Step 4: Teste do store (falha: o store não existe)** + +Criar `apps/acs/test/micro_area_cache_store_test.dart`. Copie o cabeçalho de `test/sync_cursor_store_test.dart` (imports, `InMemoryDatabaseKeyStore`, nome de banco único por teste e remoção do arquivo no `tearDown`) e use `MicroAreaCacheStore(keyStore: ..., databaseName: ..., allowUnencryptedForTesting: true)`: + +```dart +MicroAreaPatient _paciente(int n, {bool cronico = false}) => MicroAreaPatient( + patientId: syntheticPatientId(n), + name: 'Paciente Sintético $n', + isChronic: cronico, + chronicConditions: cronico ? ['hipertensão', 'diabetes'] : const [], + ); + +// ... dentro de main(), com o store criado no setUp: +test('sem nada gravado, read devolve null', () async { + expect(await store.read(owner: 'u1|m1'), isNull); +}); + +test('grava e lê de volta a lista, a data e as condições crônicas', () async { + final em = DateTime.utc(2026, 10, 2, 8); + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2, cronico: true)], at: em); + + final lido = await store.read(owner: 'u1|m1'); + + expect(lido!.fetchedAt, em); + expect(lido.patients.map((p) => p.patientId), [syntheticPatientId(1), syntheticPatientId(2)]); + expect(lido.patients.last.isChronic, isTrue); + expect(lido.patients.last.chronicConditions, ['hipertensão', 'diabetes']); +}); + +test('outro dono apaga e não serve', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + + expect(await store.read(owner: 'u2|m1'), isNull, reason: 'outro usuário'); + expect(await store.read(owner: 'u1|m1'), isNull, reason: 'o cache do dono antigo foi APAGADO, não só escondido'); +}); + +test('regravar substitui a lista inteira (paciente que saiu da microárea some)', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2)], at: DateTime.utc(2026, 10, 2)); + await store.write(owner: 'u1|m1', patients: [_paciente(2)], at: DateTime.utc(2026, 10, 3)); + + final lido = await store.read(owner: 'u1|m1'); + expect(lido!.patients.map((p) => p.patientId), [syntheticPatientId(2)]); +}); + +test('clear apaga tudo', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + await store.clear(); + expect(await store.read(owner: 'u1|m1'), isNull); +}); +``` + +(`syntheticPatientId` vem de `test/support/fakes.dart`; `MicroAreaPatient` de `package:sinalacs_client/sinalacs_client.dart`.) + +Run: `cd apps/acs && flutter test test/micro_area_cache_store_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`micro_area_cache_store.dart` não existe). + +- [ ] **Step 5: Implementar o store** + +Criar `lib/core/database/micro_area_cache_store.dart`: + +```dart +import 'dart:convert'; + +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' show ConflictAlgorithm, Database; + +class CachedMicroArea { + const CachedMicroArea({required this.patients, required this.fetchedAt}); + + final List patients; + final DateTime fetchedAt; +} + +/// Última lista da microárea no aparelho (RF08), na base SQLCipher das visitas. +/// +/// Política em `spec/lgpd_design.md` §5.7: a lista pertence a um dono +/// (`userId|microAreaId`); [read] de **outro** dono apaga o cache em vez de +/// só escondê-lo — dado de outro território não fica no disco esperando uma +/// confusão. +class MicroAreaCacheStore { + MicroAreaCacheStore({ + required DatabaseKeyStore keyStore, + this.databaseName = 'sinalacs_acs.db', + this.allowUnencryptedForTesting = false, + }) : _keyStore = keyStore; + + static const _tabela = 'micro_area_cache'; + static const _meta = 'micro_area_cache_meta'; + static const _chaveDono = 'owner'; + static const _chaveData = 'fetched_at'; + + final DatabaseKeyStore _keyStore; + final String databaseName; + final bool allowUnencryptedForTesting; + Database? _database; + + Future _open() async { + final existente = _database; + if (existente != null && existente.isOpen) return existente; + final passphrase = await _keyStore.readOrCreate(); + return _database = await EncryptedLocalDatabase.open( + databaseName: databaseName, + passphrase: passphrase, + allowUnencryptedForTesting: allowUnencryptedForTesting, + ); + } + + Future read({required String owner}) async { + final db = await _open(); + final meta = { + for (final linha in await db.query(_meta)) linha['key'] as String: linha['value'] as String, + }; + final donoGravado = meta[_chaveDono]; + final data = meta[_chaveData]; + if (donoGravado == null || data == null) return null; + if (donoGravado != owner) { + await clear(); + return null; + } + + final linhas = await db.query(_tabela, orderBy: 'rowid'); + return CachedMicroArea( + fetchedAt: DateTime.parse(data), + patients: [ + for (final linha in linhas) + MicroAreaPatient( + patientId: linha['patient_id'] as String, + name: linha['name'] as String, + isChronic: (linha['is_chronic'] as int) == 1, + chronicConditions: (jsonDecode(linha['chronic_conditions'] as String) as List).cast(), + ), + ], + ); + } + + Future write({ + required String owner, + required List patients, + required DateTime at, + }) async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + for (final p in patients) { + await txn.insert(_tabela, { + 'patient_id': p.patientId, + 'name': p.name, + 'is_chronic': p.isChronic ? 1 : 0, + 'chronic_conditions': jsonEncode(p.chronicConditions), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await txn.insert(_meta, {'key': _chaveDono, 'value': owner}, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.insert(_meta, {'key': _chaveData, 'value': at.toUtc().toIso8601String()}, + conflictAlgorithm: ConflictAlgorithm.replace); + }); + } + + Future clear() async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + await txn.delete(_meta); + }); + } +} +``` + +Run: `cd apps/acs && flutter test test/micro_area_cache_store_test.dart 2>&1 | tail -3` +Expected: `All tests passed!` (5 testes). + +- [ ] **Step 6: Teste do `MicroAreaDirectory` (falha: não existe)** + +Criar `apps/acs/test/micro_area_directory_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; + +/// Store em memória: o que se testa aqui é a REGRA (quando serve cache), não o SQLite. +class _Store implements MicroAreaCacheStore { + String? owner; + List? patients; + DateTime? at; + int clears = 0; + bool falhaAoGravar = false; + + @override + Future read({required String owner}) async { + if (this.owner == null) return null; + if (this.owner != owner) { + await clear(); + return null; + } + return CachedMicroArea(patients: patients!, fetchedAt: at!); + } + + @override + Future write({required String owner, required List patients, required DateTime at}) async { + if (falhaAoGravar) throw StateError('disco cheio'); + this.owner = owner; + this.patients = patients; + this.at = at; + } + + @override + Future clear() async { + clears++; + owner = null; + patients = null; + at = null; + } +} + +AuthSession _sessao({String userId = 'u1', String? microAreaId = 'm1'}) => AuthSession( + accessToken: 't', + tokenType: 'Bearer', + userId: userId, + role: 'acs', + microAreaId: microAreaId, + expiresAt: DateTime.utc(2030), + ); + +MicroAreaPatient _p(int n) => + MicroAreaPatient(patientId: syntheticPatientId(n), name: 'P$n', isChronic: false, chronicConditions: const []); + +void main() { + late _Store store; + late DateTime agora; + late AuthSession? sessao; + late Future> Function() busca; + + MicroAreaDirectory diretorio() => MicroAreaDirectory( + fetch: () => busca(), + session: () => sessao, + store: store, + clock: () => agora, + ); + + setUp(() { + store = _Store(); + agora = DateTime.utc(2026, 10, 2, 8); + sessao = _sessao(); + busca = () async => [_p(1), _p(2)]; + }); + + test('com rede: devolve a lista fresca e grava', () async { + final r = await diretorio().load(); + + expect(r.fromCache, isFalse); + expect(r.patients, hasLength(2)); + expect(store.patients, hasLength(2)); + expect(store.owner, 'u1|m1'); + }); + + test('sem rede, com cache do mesmo dono: serve o cache e diz a data', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 5)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + final r = await diretorio().load(); + + expect(r.fromCache, isTrue); + expect(r.patients, hasLength(2)); + expect(r.fetchedAt, DateTime.utc(2026, 10, 2, 8)); + }); + + test('sem rede e sem cache: a falha original sobe', () async { + busca = () async => throw const BackendFailure('Sem conexão.'); + expect(diretorio().load(), throwsA(isA())); + }); + + test('recusa do servidor NUNCA cai no cache', () async { + await diretorio().load(); + busca = () async => throw const BackendFailure('Sessão inválida.', isRecoverable: false); + + expect(diretorio().load(), throwsA(isA())); + }); + + test('outro dono apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(userId: 'u2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull, reason: 'o cache do outro usuário foi apagado'); + }); + + test('outra microárea apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(microAreaId: 'm2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('vencido não serve e é apagado', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 73)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('exatamente 72 h ainda serve', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 72)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect((await diretorio().load()).fromCache, isTrue); + }); + + test('falha ao gravar o cache não derruba a lista fresca', () async { + store.falhaAoGravar = true; + final r = await diretorio().load(); + expect(r.patients, hasLength(2)); + expect(r.fromCache, isFalse); + }); + + test('sem sessão não há dono: nada é lido nem gravado', () async { + sessao = null; + final r = await diretorio().load(); + expect(r.fromCache, isFalse); + expect(store.owner, isNull); + }); + + test('lista vazia fresca substitui o cache (a microárea esvaziou)', () async { + await diretorio().load(); + busca = () async => []; + await diretorio().load(); + expect(store.patients, isEmpty); + }); +} +``` + +Run: `cd apps/acs && flutter test test/micro_area_directory_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`micro_area_directory.dart` não existe). + +- [ ] **Step 7: Implementar o serviço e a fábrica** + +Criar `lib/core/services/micro_area_directory.dart`: + +```dart +import 'dart:developer' as developer; + +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +/// Validade do cache da microárea (spec/lgpd_design.md §5.7). +const microAreaCacheMaxAge = Duration(hours: 72); + +class MicroAreaSnapshot { + const MicroAreaSnapshot({required this.patients, required this.fetchedAt, required this.fromCache}); + + final List patients; + final DateTime fetchedAt; + + /// `true` quando a lista veio do aparelho porque a central não respondeu. + final bool fromCache; +} + +/// Lista da microárea com cache no aparelho (RF08). +/// +/// Regra única, em um lugar só: o cache entra **apenas** no lugar de uma falha +/// recuperável (`isRecoverable`) e **apenas** para o mesmo `userId|microAreaId` +/// e dentro da validade. Recusa do servidor (`isRecoverable: false`) sobe como +/// veio — um território negado não pode ser contornado por um cache antigo. +class MicroAreaDirectory { + MicroAreaDirectory({ + required Future> Function() fetch, + required AuthSession? Function() session, + required MicroAreaCacheStore store, + DateTime Function()? clock, + this.maxAge = microAreaCacheMaxAge, + }) : _fetch = fetch, + _session = session, + _store = store, + _clock = clock ?? DateTime.now; + + final Future> Function() _fetch; + final AuthSession? Function() _session; + final MicroAreaCacheStore _store; + final DateTime Function() _clock; + final Duration maxAge; + + String? _owner() { + final s = _session(); + if (s == null || s.microAreaId == null) return null; + return '${s.userId}|${s.microAreaId}'; + } + + Future load() async { + final owner = _owner(); + try { + final fresh = await _fetch(); + final at = _clock().toUtc(); + if (owner != null) { + try { + await _store.write(owner: owner, patients: fresh, at: at); + } catch (error, stack) { + // Gravar o cache é conforto; nunca derruba a lista que acabou de chegar. + developer.log('não foi possível gravar o cache da microárea', + name: 'sinalacs.acs.micro_area_directory', error: error, stackTrace: stack); + } + } + return MicroAreaSnapshot(patients: fresh, fetchedAt: at, fromCache: false); + } on BackendFailure catch (falha) { + if (!falha.isRecoverable || owner == null) rethrow; + final guardado = await _lerSemFalhar(owner); + if (guardado == null) rethrow; + if (_clock().toUtc().difference(guardado.fetchedAt) > maxAge) { + await _apagarSemFalhar(); + rethrow; + } + return MicroAreaSnapshot(patients: guardado.patients, fetchedAt: guardado.fetchedAt, fromCache: true); + } + } + + Future _lerSemFalhar(String owner) async { + try { + return await _store.read(owner: owner); + } catch (_) { + return null; // cache ilegível = sem cache; a falha da rede é a que importa + } + } + + Future _apagarSemFalhar() async { + try { + await _store.clear(); + } catch (_) {} + } +} +``` + +Criar `lib/core/services/micro_area_directory_factory.dart` (mesmo desenho de `visit_pull_service_factory.dart`: em produção o `keyStore` é o do Keystore/Keychain do aparelho e `allowUnencryptedForTesting` fica `false`): + +```dart +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; + +/// Monta o diretório da microárea sobre o backend e a base SQLCipher do aparelho. +/// +/// [cacheStore] existe só para um teste inspecionar o que foi gravado; em +/// produção a chamada não passa nada e usa o padrão, respaldado pelo +/// Keystore/Keychain. +MicroAreaDirectory buildMicroAreaDirectory({ + required AcsBackend backend, + MicroAreaCacheStore? cacheStore, +}) => + MicroAreaDirectory( + fetch: backend.listPatients, + session: () => backend.session, + store: cacheStore ?? MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore()), + ); +``` + +Os testes de widget que **não** injetam um `MicroAreaDirectory` usam este de produção; o store só abre a base na primeira leitura/gravação e, no `flutter test`, o Keystore não existe — a falha é engolida pelo próprio `MicroAreaDirectory` (`falha ao gravar o cache não derruba a lista fresca`), então esses testes seguem verdes. + +Run: `cd apps/acs && flutter test test/micro_area_directory_test.dart 2>&1 | tail -3` +Expected: `All tests passed!` (11 testes). + +- [ ] **Step 8: Ligar nos dois pontos de chamada, com aviso** + +Em `app.dart`: + +1. `_SinalAcsAppState` (onde `_visitStore`/`buildVisitPullService` são montados, linhas ~81–98): acrescentar `late final MicroAreaDirectory _microAreaDirectory = widget.microAreaDirectory ?? buildMicroAreaDirectory(backend: widget.backend);` e o parâmetro opcional `MicroAreaDirectory? microAreaDirectory` em `SinalAcsApp` (para os testes), repassado a `AcsHomeShell` como `directory`. +2. `AcsHomeShell._loadMicroAreaPatients` (~436): trocar `final patients = await backend.listPatients();` por + ```dart + final snapshot = widget.directory == null + ? null + : await widget.directory!.load(); + final patients = snapshot?.patients ?? await backend.listPatients(); + ``` + e, no `setState`, `_microAreaPatientsLoadedAt = snapshot?.fetchedAt ?? DateTime.now();` e `_microAreaFromCache = snapshot?.fromCache ?? false;` (campo novo, `false` no início de cada carga). +3. `_VisitRegistrationScreenState._loadPatients` (~1602): receber `directory` por parâmetro opcional de `VisitRegistrationScreen` (o shell o repassa) e trocar `final result = await BackendScope.of(context).listPatients();` por `final snapshot = widget.directory == null ? null : await widget.directory!.load(); final result = snapshot?.patients ?? await BackendScope.of(context).listPatients();` guardando `_patientsFromCache = snapshot?.fromCache ?? false; _patientsFetchedAt = snapshot?.fetchedAt;`. +4. Aviso (nas duas telas, logo acima da lista/contador, quando `fromCache`), com a chave `micro_area_cache_notice` na tela de visita e `micro_area_cache_notice_area` na de Área: + +```dart +if (_patientsFromCache && _patientsFetchedAt != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + key: const Key('micro_area_cache_notice'), + 'Sem conexão com a central: lista salva em ${_formatarDataHora(_patientsFetchedAt!)}.', + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + ), + ), + ), +``` + +com a função de nível de arquivo (sem `intl`, o app não a usa): + +```dart +String _formatarDataHora(DateTime utc) { + final d = utc.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(d.day)}/${dois(d.month)} às ${dois(d.hour)}:${dois(d.minute)}'; +} +``` + +Os dois parâmetros novos são **opcionais**: os 220+ testes existentes, que não os passam, seguem iguais. + +- [ ] **Step 9: Teste de widget do aviso e do offline na tela de visita** + +Em `apps/acs/test/login_flow_test.dart` (ou arquivo novo `micro_area_cache_ui_test.dart` no mesmo estilo de `login_flow_test.dart`), caso: `FakeAcsBackend` com `patients` preenchidos; um `MicroAreaDirectory(fetch: backend.listPatients, session: () => backend.session, store: _Store())` com o `_Store` em memória do passo 6; abrir a aba Visita (primeira carga grava), depois `backend.listPatientsFailure = const BackendFailure('Sem conexão.')`, tocar "Tentar de novo"/reabrir a aba, e afirmar que `find.byKey(const Key('micro_area_cache_notice'))` aparece e que o paciente continua em `patient_picker`. Outro caso: com `listPatientsFailure = BackendFailure('Sessão inválida.', isRecoverable: false)` o aviso **não** aparece e o erro da tela sim. + +Run: `cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test 2>&1 | tail -2` +Expected: `No issues found!`; `All tests passed!` com **≥ 242** testes (224 + 1 migração + 5 store + 11 diretório + 2 de tela). + +- [ ] **Step 10: Prova no emulador: cacheia, cai a rede, serve, e o disco está cifrado** + +Em `integration_test/full_journey_e2e.dart`, acrescentar um `testWidgets` **entre** o da rajada e o fim do arquivo (antes de qualquer teste de MFA da Task 6), com os `import`s de `micro_area_cache_store.dart`, `micro_area_directory.dart`, `database_key_store.dart` e `encrypted_database.dart`: + +```dart + testWidgets('RF08: a microárea fica no aparelho, sobrevive à queda de rede e está cifrada', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + // O `keyStore` de PRODUÇÃO (Keystore do aparelho), numa base própria deste teste. + final store = MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore(), databaseName: 'rf08_e2e.db'); + final online = MicroAreaDirectory(fetch: backend.listPatients, session: () => backend.session, store: store); + final primeira = await online.load(); + expect(primeira.fromCache, isFalse); + expect(primeira.patients.any((p) => p.patientId == main.id), isTrue); + + // Rede caída: a central "não responde", o aparelho serve o que guardou. + final offline = MicroAreaDirectory( + fetch: () async => throw const BackendFailure('Sem conexão.'), + session: () => backend.session, + store: store, + ); + final segunda = await offline.load(); + expect(segunda.fromCache, isTrue); + expect(segunda.patients.any((p) => p.patientId == main.id), isTrue); + + // O arquivo no disco NÃO contém o nome do paciente em texto claro (SQLCipher no aparelho). + final bytes = await File(await EncryptedLocalDatabase.pathFor('rf08_e2e.db')).readAsBytes(); + expect(String.fromCharCodes(bytes).contains(main.name), isFalse, + reason: 'o nome do paciente não pode estar legível no arquivo do banco'); + expect(String.fromCharCodes(bytes.take(16)), isNot(startsWith('SQLite format 3'))); + await store.clear(); + }); +``` + +Run: `./scripts/qa/acs_full_e2e.sh 2>&1 | grep -E "OK —|Some tests|Expected|Actual|rc="; docker compose up -d` +Expected: `OK — jornada completa do ACS contra o banco de teste`. Se a asserção do nome falhar, a base **não** está cifrada neste caminho: é achado de segurança — pare e investigue (`allowUnencryptedForTesting` ligado fora de teste?), não relaxe a asserção. + +- [ ] **Step 11: Documentar e commitar** + +`PROGRESS.md`: marcar RF08 como "cache persistido e cifrado (72 h, por dono)" e remover "RF08 persistido" de "Continua aberto"; `apps/CLAUDE.md`: um parágrafo curto sobre `MicroAreaDirectory`/`MicroAreaCacheStore`; `spec/validation_report.md`: se a linha RF08 diz "parcial", atualizar a frase e rodar `python3 scripts/qa/contagem_validation_report.py` (a contagem muda de `parcial` para `backend + app`; ajuste a linha **Contagem** à mão e confira que o conferidor volta a `ok`). + +```bash +git add apps/acs spec PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): RF08 — microárea persistida na base SQLCipher, por dono e com validade de 72 h" +``` + +--- + +### Task 5: MFA (TOTP) no login institucional — backend + +**Files:** +- Create: `backend/sinalacs_server/lib/src/application/auth/totp.dart` +- Create: `backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart` +- Create: `backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart` +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart` +- Modify: `backend/sinalacs_server/lib/src/models/user_credential.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml`, `mfa_enrollment_required_exception.spy.yaml`, `backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml` +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart`, `lib/src/runtime/alert_runtime.dart`, `lib/src/config/app_config.dart`, `.env.example`, `docker-compose.yml` +- Test: `backend/sinalacs_server/test/unit/totp_test.dart`, `backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart`, `backend/sinalacs_server/test/unit/app_config_test.dart` + +**Interfaces:** +- Consumes: `InstitutionalAuthService` (`store`, `hasher`, `audit`, `maxFailedAttempts`, `lockDuration`, `_recordAudit`, `_invalidCredentials`), `AcsCredentialRecord`, `PasswordHasher.derive/matches`, `HealthDataCipher.encrypt(String) -> EncryptedValue{ciphertextBase64,keyVersion}` / `decrypt(EncryptedValue) -> String`. +- Produces: + - `Totp.code(Uint8List secret, DateTime at) -> String`; `Totp.stepOf(DateTime) -> int`; `Totp.verify(Uint8List secret, String code, DateTime at, {int window = 1, int? lastStep}) -> int?`; `Totp.base32(Uint8List) -> String`; `Totp.otpauthUri({required String secretBase32, required String account, String issuer = 'SinalACS'}) -> String`. + - `class SealedSecret { String ciphertextBase64; int keyVersion }`; `abstract interface class TotpSecretVault { Future seal(Uint8List); Future open(SealedSecret); }`. + - `class TotpEnrollment { SealedSecret sealed; bool enabled; int? lastStep }`; `AcsCredentialRecord.totp` (opcional, `null` = sem MFA). + - `abstract interface class TotpStore { Future saveSecret(String acsId, SealedSecret s, DateTime at); Future enable(String acsId, int step, DateTime at); Future registerStep(String acsId, int step); }` + - `InstitutionalAuthService({..., TotpStore? totpStore, TotpSecretVault? vault, bool requireMfa = false, Random? random})`; `login(..., String? totpCode)`; `beginTotpEnrollment({matricula, password}) -> TotpEnrollmentStart`; `confirmTotpEnrollment({matricula, password, code})`. + - RPC: `auth.loginInstitutional(matricula:, password:, deviceId:, totpCode:)`, `auth.beginTotpEnrollment(matricula:, password:)`, `auth.confirmTotpEnrollment(matricula:, password:, code:)`; exceções `MfaRequiredException{message}`, `MfaEnrollmentRequiredException{message}`. + +- [ ] **Step 1: Teste do `Totp` com os vetores da RFC 6238 (falha: não existe)** + +Criar `backend/sinalacs_server/test/unit/totp_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:test/test.dart'; + +void main() { + // Segredo da RFC 6238 (Apêndice B): ASCII "12345678901234567890". + final segredo = Uint8List.fromList(ascii.encode('12345678901234567890')); + DateTime em(int segundos) => DateTime.fromMillisecondsSinceEpoch(segundos * 1000, isUtc: true); + + group('RFC 6238 — vetores do SHA-1 (6 últimos dígitos)', () { + final vetores = { + 59: '287082', + 1111111109: '081804', + 1111111111: '050471', + 1234567890: '005924', + 2000000000: '279037', + 20000000000: '353130', + }; + vetores.forEach((t, esperado) { + test('T=$t → $esperado', () => expect(Totp.code(segredo, em(t)), esperado)); + }); + }); + + test('base32 do segredo da RFC é o valor conhecido', () { + expect(Totp.base32(segredo), 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); + }); + + test('o passo muda a cada 30 s', () { + expect(Totp.stepOf(em(29)), 0); + expect(Totp.stepOf(em(30)), 1); + }); + + group('verify', () { + test('aceita o código do passo atual e devolve o passo', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora), Totp.stepOf(agora)); + }); + + test('aceita um passo antes e um depois (relógio fora por até 30 s)', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.subtract(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) - 1); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) + 1); + }); + + test('recusa dois passos de distância', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 60))), agora), isNull); + }); + + test('recusa passo já usado (replay): só aceita passo MAIOR que lastStep', () { + final agora = em(1111111111); + final passo = Totp.stepOf(agora); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo), isNull); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo - 1), passo); + }); + + test('recusa código com tamanho errado ou não numérico, sem lançar', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, '12345', agora), isNull); + expect(Totp.verify(segredo, '1234567', agora), isNull); + expect(Totp.verify(segredo, 'abcdef', agora), isNull); + expect(Totp.verify(segredo, '', agora), isNull); + }); + + test('ignora espaços em volta do código (apps agrupam "123 456")', () { + final agora = em(1111111111); + final c = Totp.code(segredo, agora); + expect(Totp.verify(segredo, '${c.substring(0, 3)} ${c.substring(3)}', agora), Totp.stepOf(agora)); + }); + }); + + test('otpauthUri tem o formato que os autenticadores leem', () { + final uri = Totp.otpauthUri(secretBase32: 'ABC234', account: 'ACS-001'); + expect(uri, 'otpauth://totp/SinalACS:ACS-001?secret=ABC234&issuer=SinalACS&algorithm=SHA1&digits=6&period=30'); + }); +} +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/totp_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`totp.dart` não existe). + +- [ ] **Step 2: Implementar o `Totp`** + +Criar `lib/src/application/auth/totp.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// TOTP (RFC 6238) com SHA-1, 6 dígitos e passo de 30 s — o padrão que todo +/// aplicativo autenticador entende. Função pura: sem relógio, sem I/O. +class Totp { + Totp._(); + + static const period = 30; + static const digits = 6; + static const _alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + static int stepOf(DateTime at) => at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ period; + + static String code(Uint8List secret, DateTime at) => _hotp(secret, stepOf(at)); + + /// Passo (`stepOf`) em que [code] confere, dentro de ±[window] passos de [at], + /// ou `null`. Com [lastStep], só aceita passo **maior** que ele: é o que + /// impede reaproveitar um código já usado dentro da janela (replay). + static int? verify( + Uint8List secret, + String code, + DateTime at, { + int window = 1, + int? lastStep, + }) { + final limpo = code.replaceAll(RegExp(r'\s'), ''); + if (limpo.length != digits || !RegExp(r'^\d+$').hasMatch(limpo)) return null; + final atual = stepOf(at); + for (var passo = atual - window; passo <= atual + window; passo++) { + if (lastStep != null && passo <= lastStep) continue; + if (_hotp(secret, passo) == limpo) return passo; + } + return null; + } + + static String _hotp(Uint8List secret, int counter) { + final mensagem = ByteData(8)..setUint64(0, counter); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(digits, '0'); + } + + /// Base32 (RFC 4648, sem preenchimento): o formato do segredo que o aplicativo + /// autenticador pede quando a pessoa não lê o QR. + static String base32(Uint8List bytes) { + var bits = 0; + var valor = 0; + final saida = StringBuffer(); + for (final b in bytes) { + valor = (valor << 8) | b; + bits += 8; + while (bits >= 5) { + saida.write(_alfabeto[(valor >> (bits - 5)) & 31]); + bits -= 5; + } + valor &= (1 << bits) - 1; + } + if (bits > 0) saida.write(_alfabeto[(valor << (5 - bits)) & 31]); + return saida.toString(); + } + + static String otpauthUri({ + required String secretBase32, + required String account, + String issuer = 'SinalACS', + }) => + 'otpauth://totp/${Uri.encodeComponent(issuer)}:${Uri.encodeComponent(account)}' + '?secret=$secretBase32&issuer=${Uri.encodeComponent(issuer)}' + '&algorithm=SHA1&digits=$digits&period=$period'; +} +``` + +Run: `dart test test/unit/totp_test.dart 2>&1 | tail -3` +Expected: `All tests passed!` (todos). Se um vetor da RFC falhar, o defeito é do `_hotp` (endianness do contador ou truncamento dinâmico), nunca do vetor. + +- [ ] **Step 3: Modelo, exceções e migração** + +`user_credential.spy.yaml`, acrescentar ao fim de `fields:` (antes de `indexes:`): + +```yaml + ### MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + ### (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + totpSecretEncrypted: String? + totpKeyVersion: int? + ### `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + totpEnabledAt: DateTime? + ### Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + totpLastStep: int? +``` + +Criar as duas exceções copiando o formato de `authentication_failed_exception.spy.yaml`: + +```yaml +### O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +### Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +exception: MfaRequiredException +fields: + message: String +``` + +```yaml +### O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +### ativar (auth.beginTotpEnrollment) antes de entrar. +exception: MfaEnrollmentRequiredException +fields: + message: String +``` + +`models/api/totp_enrollment_start.spy.yaml`: + +```yaml +### Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +### `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +class: TotpEnrollmentStart +fields: + secretBase32: String + otpauthUri: String +``` + +Run: `cd backend/sinalacs_server && serverpod generate 2>&1 | tail -3 && serverpod create-migration 2>&1 | tail -3` +Expected: sem erro; a migração nova só tem `ALTER TABLE "user_credentials" ADD COLUMN ...` das 4 colunas, todas nullable. + +- [ ] **Step 4: Testes do serviço (falham: não há MFA no serviço)** + +Criar `backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _senha = 'senha-sintetica-de-teste'; + +/// Cofre de teste: base64 reversível. Quem prova a cifra real é `health_data_cipher_test.dart`. +class _Cofre implements TotpSecretVault { + @override + Future seal(Uint8List secret) async => + SealedSecret(ciphertextBase64: base64Encode(secret), keyVersion: 1); + + @override + Future open(SealedSecret sealed) async => base64Decode(sealed.ciphertextBase64); +} + +class _Store implements AcsCredentialStore, TotpStore { + _Store(this.record); + AcsCredentialRecord record; + int enabledCalls = 0; + int? lastRegisteredStep; + + @override + Future findByEnrollmentId(String enrollmentId) async => + enrollmentId == 'ACS-001' ? record : null; + + @override + Future registerFailedAttempt(String acsId, + {required bool restartCounter, + required int maxFailedAttempts, + required DateTime lockUntil, + required DateTime at}) async { + final anterior = record.failedAttempts; + final proximo = restartCounter ? 1 : anterior + 1; + record = _copia(failedAttempts: proximo, lockedUntil: proximo >= maxFailedAttempts ? lockUntil : null); + } + + @override + Future registerSuccessfulLogin(String acsId, DateTime at) async { + record = _copia(failedAttempts: 0, lockedUntil: null); + } + + @override + Future saveCredential(String acsId, PasswordDigest digest, DateTime at) async {} + + @override + Future saveSecret(String acsId, SealedSecret s, DateTime at) async { + record = _copia(totp: TotpEnrollment(sealed: s, enabled: false, lastStep: null)); + } + + @override + Future enable(String acsId, int step, DateTime at) async { + enabledCalls++; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: true, lastStep: step)); + } + + @override + Future registerStep(String acsId, int step) async { + lastRegisteredStep = step; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: t.enabled, lastStep: step)); + } + + AcsCredentialRecord _copia({int? failedAttempts, DateTime? lockedUntil, TotpEnrollment? totp}) => + AcsCredentialRecord( + acsId: record.acsId, + microAreaId: record.microAreaId, + active: record.active, + digest: record.digest, + failedAttempts: failedAttempts ?? record.failedAttempts, + lockedUntil: failedAttempts != null ? lockedUntil : record.lockedUntil, + totp: totp ?? record.totp, + ); +} + +class _Audit extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +void main() { + final hasher = Argon2PasswordHasher(memoryKb: 512, iterations: 1, parallelism: 1); + final cofre = _Cofre(); + final segredo = Uint8List.fromList(List.generate(20, (i) => i + 1)); + final t0 = DateTime.utc(2026, 10, 2, 12); + + Future<({InstitutionalAuthService servico, _Store store})> montar({ + bool comMfa = true, + bool exigir = false, + }) async { + final digest = await hasher.derive(_senha); + final sealed = await cofre.seal(segredo); + final store = _Store(AcsCredentialRecord( + acsId: _acsId, + microAreaId: _microAreaId, + active: true, + digest: digest, + failedAttempts: 0, + lockedUntil: null, + totp: comMfa ? TotpEnrollment(sealed: sealed, enabled: true, lastStep: null) : null, + )); + final servico = InstitutionalAuthService( + store: store, + hasher: hasher, + audit: _Audit(), + totpStore: store, + vault: cofre, + requireMfa: exigir, + random: Random(7), + ); + return (servico: servico, store: store); + } + + group('login com MFA ativa', () { + test('senha certa sem código → MfaRequiredException, sem contar tentativa', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + expect(m.store.record.failedAttempts, 0); + }); + + test('senha certa + código certo → entra e grava o passo usado', () async { + final m = await montar(); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + expect(m.store.lastRegisteredStep, Totp.stepOf(t0)); + }); + + test('recusa o mesmo código usado duas vezes (replay)', () async { + final m = await montar(); + final codigo = Totp.code(segredo, t0); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0); + + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0), + throwsA(isA()), + ); + }); + + test('código do passo SEGUINTE ainda entra depois de um login (relógio adiantado)', () async { + final m = await montar(); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + final proximo = t0.add(const Duration(seconds: 30)); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, proximo), now: proximo); + expect(user.id, _acsId); + }); + + test('código errado conta tentativa e tranca no 5º erro', () async { + final m = await montar(); + for (var i = 0; i < 4; i++) { + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + } + expect(m.store.record.failedAttempts, 4); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.record.lockedUntil, isNotNull); + }); + + test('código errado não consome o passo (o certo logo depois entra)', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.lastRegisteredStep, isNull); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + }); + + test('senha errada continua dando a mensagem única, com ou sem código', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: 'errada', totpCode: Totp.code(segredo, t0), now: t0), + throwsA(isA()), + ); + }); + }); + + group('login sem MFA ativa', () { + test('com REQUIRE_ACS_MFA desligado, entra só com a senha', () async { + final m = await montar(comMfa: false); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + + test('com REQUIRE_ACS_MFA ligado, a senha certa recebe MfaEnrollmentRequiredException', () async { + final m = await montar(comMfa: false, exigir: true); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + }); + + test('enrollment começado e NÃO confirmado não vale: entra só com a senha', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(m.store.record.totp!.enabled, isFalse); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + }); + + group('ativação', () { + test('begin devolve segredo e URI; confirm com o código certo ativa', () async { + final m = await montar(comMfa: false); + final inicio = await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(inicio.otpauthUri, startsWith('otpauth://totp/SinalACS:ACS-001?secret=${inicio.secretBase32}')); + + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: 'ACS-001', + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + expect(m.store.enabledCalls, 1); + expect(m.store.record.totp!.enabled, isTrue); + }); + + test('confirm com código errado não ativa e conta tentativa', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + await expectLater( + m.servico.confirmTotpEnrollment(matricula: 'ACS-001', password: _senha, code: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.enabledCalls, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('begin com senha errada é recusado como o login (mensagem única)', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: 'errada'), + throwsA(isA()), + ); + }); + + test('begin com MFA já ativa é recusado (redefinir exige a coordenação)', () async { + final m = await montar(); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha), + throwsA(isA()), + ); + }); + }); +} +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_mfa_test.dart 2>&1 | tail -5` +Expected: **FAIL** de compilação (`totp_secret_vault.dart`, `TotpStore`, `TotpEnrollment`, parâmetros `totpCode`/`requireMfa` não existem). + +- [ ] **Step 5: Implementar cofre, estado e serviço** + +Criar `lib/src/application/auth/totp_secret_vault.dart`: + +```dart +import 'dart:typed_data'; + +/// Segredo TOTP já cifrado, com a versão da chave que o cifrou. +class SealedSecret { + const SealedSecret({required this.ciphertextBase64, required this.keyVersion}); + + final String ciphertextBase64; + final int keyVersion; +} + +/// Cifra e decifra o segredo TOTP. A camada de aplicação não conhece a cifra: +/// quem decide o algoritmo é `infrastructure/` (`HealthCipherTotpVault`). +abstract interface class TotpSecretVault { + Future seal(Uint8List secret); + Future open(SealedSecret sealed); +} +``` + +Criar `lib/src/infrastructure/crypto/health_cipher_totp_vault.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; + +/// Cofre do segredo TOTP sobre a mesma AES-256-GCM dos dados clínicos +/// (`HEALTH_DATA_ENCRYPTION_KEY`). Uma chave a menos para gerir; o custo é que +/// quem a perde, perde também as MFAs — e a redefinição é manual (decisão D6). +class HealthCipherTotpVault implements TotpSecretVault { + HealthCipherTotpVault(this._cipher); + + final HealthDataCipher _cipher; + + @override + Future seal(Uint8List secret) async { + final v = await _cipher.encrypt(base64Encode(secret)); + return SealedSecret(ciphertextBase64: v.ciphertextBase64, keyVersion: v.keyVersion); + } + + @override + Future open(SealedSecret sealed) async { + final claro = await _cipher.decrypt( + EncryptedValue(ciphertextBase64: sealed.ciphertextBase64, keyVersion: sealed.keyVersion), + ); + return base64Decode(claro); + } +} +``` + +Em `institutional_auth_service.dart`: + +1. Imports: `dart:math`, `dart:typed_data`, `totp.dart`, `totp_secret_vault.dart`. +2. Antes de `AcsCredentialRecord`, acrescentar: + +```dart +/// Estado da MFA de um ACS, como está gravado. +class TotpEnrollment { + const TotpEnrollment({required this.sealed, required this.enabled, required this.lastStep}); + + final SealedSecret sealed; + + /// `false` = a ativação começou e não foi confirmada: a MFA ainda NÃO vale. + final bool enabled; + final int? lastStep; +} + +/// Escrita do estado da MFA. Interface à parte de [AcsCredentialStore] de +/// propósito: quem só lê credencial (e os fakes que já existem) não muda. +abstract interface class TotpStore { + Future saveSecret(String acsId, SealedSecret secret, DateTime at); + Future enable(String acsId, int step, DateTime at); + Future registerStep(String acsId, int step); +} +``` + +3. Em `AcsCredentialRecord`: parâmetro **opcional** `this.totp` no construtor e `final TotpEnrollment? totp;` (`null` = sem MFA). +4. No serviço: parâmetros opcionais do construtor `this.totpStore, this.vault, this.requireMfa = false, Random? random` (`_random = random ?? Random.secure()`), e `static const _invalidCode = 'Código de verificação inválido.';`. +5. **Extrair** a parte do `login` que vai de "normaliza matrícula" até a senha conferida (e o bloqueio decidido) para um método privado `Future _authenticatePassword({required String matricula, required String password, required DateTime at})` que devolve a linha quando a senha confere e **lança** exatamente o que o `login` hoje lança nos outros casos (matrícula inexistente, senha errada, bloqueio, inativo). `login` passa a chamar esse método e seguir. **Refatoração pura:** `dart test test/unit/institutional_auth_service_test.dart` tem de continuar verde **antes** de seguir para o passo 6. +6. Em `login`, novo parâmetro `String? totpCode`, e **depois** de `_authenticatePassword` e **antes** de `store.registerSuccessfulLogin`: + +```dart + final totp = record.totp; + if (totp != null && totp.enabled) { + final codigo = totpCode?.trim() ?? ''; + if (codigo.isEmpty) { + // A senha conferiu; falta o código. Não é falha: não conta tentativa. + throw MfaRequiredException(message: 'Informe o código do aplicativo autenticador.'); + } + final segredo = await vault!.open(totp.sealed); + final passo = Totp.verify(segredo, codigo, at, lastStep: totp.lastStep); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp'); + throw AuthenticationFailedException(message: _invalidCode); + } + await totpStore!.registerStep(record.acsId, passo); + } else if (requireMfa) { + await _recordAudit(record.acsId, 'denied_mfa_not_enrolled'); + throw MfaEnrollmentRequiredException( + message: 'Ative a verificação em duas etapas antes de entrar.', + ); + } +``` + +`_registrarFalha(record, at)` chama `store.registerFailedAttempt(record.acsId, restartCounter: , maxFailedAttempts: maxFailedAttempts, lockUntil: at.add(lockDuration), at: at)` — extraia do ramo da senha errada o cálculo de `restartCounter` para um helper compartilhado e use-o nos dois lugares (o código atual está no ramo `if (!await hasher.matches(...))`). Se `vault`/`totpStore` forem `null` com MFA ativa, é erro de montagem: `StateError('MFA ativa sem cofre configurado')`. + +7. Dois métodos públicos: + +```dart + /// Começa a ativação da MFA. Exige matrícula **e senha** (o ACS ainda não tem + /// token) e conta tentativa errada como o login. Só grava o segredo como + /// "pendente": a MFA vale depois de [confirmTotpEnrollment]. + Future beginTotpEnrollment({ + required String matricula, + required String password, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + if (record.totp?.enabled ?? false) { + throw AuthenticationFailedException( + message: 'A verificação em duas etapas já está ativa. Peça a redefinição à coordenação.', + ); + } + final bytes = Uint8List.fromList(List.generate(20, (_) => _random.nextInt(256))); + await totpStore!.saveSecret(record.acsId, await vault!.seal(bytes), at); + final base32 = Totp.base32(bytes); + return TotpEnrollmentStart( + secretBase32: base32, + otpauthUri: Totp.otpauthUri(secretBase32: base32, account: matricula.trim()), + ); + } + + /// Confirma a ativação com um código válido do segredo pendente. + Future confirmTotpEnrollment({ + required String matricula, + required String password, + required String code, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + final totp = record.totp; + if (totp == null || totp.enabled) { + throw AuthenticationFailedException(message: 'Não há ativação pendente para esta matrícula.'); + } + final passo = Totp.verify(await vault!.open(totp.sealed), code, at); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_enrollment'); + throw AuthenticationFailedException(message: _invalidCode); + } + await totpStore!.enable(record.acsId, passo, at); + await _recordAudit(record.acsId, 'mfa_enabled'); + } +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_service_test.dart test/unit/institutional_auth_mfa_test.dart test/unit/totp_test.dart 2>&1 | tail -4` +Expected: `All tests passed!` (os antigos continuam verdes e os novos também). Qualquer teste **antigo** vermelho = a refatoração do passo 5 mudou comportamento: desfaça e refaça menor. + +- [ ] **Step 6: ORM, endpoint, runtime e configuração** + +`orm_acs_credential_store.dart` (siga o estilo do próprio arquivo, que já faz o `UPDATE` de tentativas): `findByEnrollmentId` passa a preencher `totp:` quando `credential.totpSecretEncrypted != null` — `TotpEnrollment(sealed: SealedSecret(ciphertextBase64: ..., keyVersion: credential.totpKeyVersion!), enabled: credential.totpEnabledAt != null, lastStep: credential.totpLastStep)`; a classe passa a implementar também `TotpStore`: + +```dart + @override + Future saveSecret(String acsId, SealedSecret secret, DateTime at) async { + await _setColumns(acsId, (t) => [ + t.totpSecretEncrypted.set(secret.ciphertextBase64), + t.totpKeyVersion.set(secret.keyVersion), + t.totpEnabledAt.set(null), + t.totpLastStep.set(null), + ]); + } + + @override + Future enable(String acsId, int step, DateTime at) async { + await _setColumns(acsId, (t) => [t.totpEnabledAt.set(at), t.totpLastStep.set(step)]); + } + + @override + Future registerStep(String acsId, int step) async { + await _setColumns(acsId, (t) => [t.totpLastStep.set(step)]); + } +``` + +com `_setColumns` usando `UserCredential.db.updateWhere(session(), columnValues: columns, where: (t) => t.userId.equals(UuidValue.fromString(acsId)))` — **confira** no arquivo como o store já obtém a sessão (`session: () => session`) e como escreve; se `updateWhere` não existir nesta versão do Serverpod, use o mesmo mecanismo (SQL nomeado) que `registerSuccessfulLogin` usa. + +`alert_runtime.dart`, em `institutionalAuthServiceFor`: + +```dart + InstitutionalAuthService institutionalAuthServiceFor(Session session) { + final store = OrmAcsCredentialStore(session: () => session); + return InstitutionalAuthService( + store: store, + hasher: passwordHasher, + audit: auditTrailFor(session), + totpStore: store, + vault: HealthCipherTotpVault(healthDataCipher), + requireMfa: config.requireAcsMfa, + ); + } +``` + +`auth_endpoint.dart`: `loginInstitutional` ganha `String? totpCode` (repassado ao serviço) e acrescentar: + +```dart + /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. + Future beginTotpEnrollment( + Session session, { + required String matricula, + required String password, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .beginTotpEnrollment(matricula: matricula, password: password); + + /// Confirma a ativação com o primeiro código do autenticador. + Future confirmTotpEnrollment( + Session session, { + required String matricula, + required String password, + required String code, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .confirmTotpEnrollment(matricula: matricula, password: password, code: code); +``` + +`app_config.dart`: campo `final bool requireAcsMfa;` e, no `fromEnvironment`, seguindo `enableDevLogin`: `requireAcsMfa: environment['REQUIRE_ACS_MFA'] != null ? environment['REQUIRE_ACS_MFA'] == 'true' : environment['APP_ENV'] != 'development'` (padrão **ligado fora de development**). Teste em `test/unit/app_config_test.dart`: em `development` sem a variável é `false`; com `APP_ENV=production` sem a variável é `true`; `REQUIRE_ACS_MFA=false` explícito desliga mesmo em produção. + +`.env.example` e `docker-compose.yml` (serviço do servidor): `REQUIRE_ACS_MFA: ${REQUIRE_ACS_MFA:-false}` com a nota "em produção o padrão é `true`; este default é só da stack local". + +- [ ] **Step 7: Gerar, analisar e rodar a suíte do backend** + +Run: `cd backend/sinalacs_server && serverpod generate 2>&1 | tail -2 && dart analyze 2>&1 | tail -2 && dart test 2>&1 | tail -3` +Expected: `No issues found!`; `All tests passed!`. A suíte completa usa o banco de teste (`docker compose --profile test up -d postgres-test`); a contagem total sobe pelos testes novos (anotar no ledger o antes/depois). + +- [ ] **Step 8: Documentar e commitar** + +`spec/lgpd_data_audit.md`: acrescentar as 4 colunas de `user_credentials` (`totpSecretEncrypted` = segredo de autenticação, cifrado; `totpKeyVersion`, `totpEnabledAt`, `totpLastStep` = metadado de segurança) e **re-medir** a contagem de tabelas/colunas. `backend/CLAUDE.md`: parágrafo sobre `REQUIRE_ACS_MFA`, o cofre sobre a chave dos dados clínicos e a redefinição manual (zerar as quatro colunas). + +```bash +git add backend spec .env.example docker-compose.yml +git commit -m "feat(auth): MFA por TOTP no login institucional do ACS (RF07, LGPD-RT06), com replay barrado" +``` + +--- + +### Task 6: MFA — app do ACS e prova no emulador + +**Files:** +- Modify: `apps/acs/lib/core/network/backend_client.dart`, `apps/acs/lib/app/app.dart` (LoginScreen) +- Create: `apps/acs/lib/app/mfa_enrollment_screen.dart` +- Create: `apps/acs/integration_test/support/totp.dart`, `apps/acs/test/support/totp.dart` (mesma implementação mínima, só para teste) +- Test: `apps/acs/test/mfa_login_test.dart`, `apps/acs/test/totp_support_test.dart` +- Modify: `apps/acs/test/support/fakes.dart`, `apps/acs/integration_test/full_journey_e2e.dart`, `PROGRESS.md`, `apps/CLAUDE.md` + +**Interfaces:** +- Consumes (Task 5): RPC `auth.loginInstitutional(…, totpCode:)`, `auth.beginTotpEnrollment`, `auth.confirmTotpEnrollment`; exceções `MfaRequiredException`, `MfaEnrollmentRequiredException`; `TotpEnrollmentStart{secretBase32, otpauthUri}`. `qr_flutter` (`QrImageView`, já usado em `invite_screen.dart`). +- Produces: + - `AcsBackend.login({required String matricula, required String senha, String? totpCode})` + - `AcsBackend.beginTotpEnrollment({required String matricula, required String senha}) -> Future`; `AcsBackend.confirmTotpEnrollment({required String matricula, required String senha, required String code}) -> Future` + - `class MfaCodeRequired extends BackendFailure`, `class MfaEnrollmentRequired extends BackendFailure` + - `class MfaEnrollmentScreen` com chaves `mfa_secret`, `mfa_code_field`, `mfa_confirm_button`. + - `String Totp.code(Uint8List secret, DateTime at)` em `test/support/totp.dart` e `integration_test/support/totp.dart`. + +- [ ] **Step 1: Testes do app (falham: não há campo de código)** + +Em `test/support/fakes.dart`, `FakeAcsBackend`: o `login` passa a aceitar `String? totpCode`, guardar `lastTotpCode` e consultar `mfaRequired`/`mfaEnrollmentRequired`: + +```dart + /// Simula o servidor com MFA ativa: sem [totpCode] igual a [expectedTotpCode], levanta [MfaCodeRequired]. + String? expectedTotpCode; + bool mfaEnrollmentRequired = false; + String? lastTotpCode; + TotpEnrollmentStart enrollmentStart = + TotpEnrollmentStart(secretBase32: 'GEZDGNBVGY3TQOJQ', otpauthUri: 'otpauth://totp/SinalACS:ACS-001?secret=GEZDGNBVGY3TQOJQ'); + String? confirmedCode; + + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) async { + confirmedCode = code; + } + + @override + Future beginTotpEnrollment({required String matricula, required String senha}) async => + enrollmentStart; +``` + +e, no início do `login` do fake (depois de registrar matrícula/senha): + +```dart + lastTotpCode = totpCode; + if (mfaEnrollmentRequired) throw const MfaEnrollmentRequired(); + if (expectedTotpCode != null && totpCode != expectedTotpCode) { + throw totpCode == null ? const MfaCodeRequired() : const BackendFailure('Código de verificação inválido.', isRecoverable: false); + } +``` + +Criar `apps/acs/test/mfa_login_test.dart` (use `entrar(tester)` e o estilo de `login_flow_test.dart`): + +```dart +// 1. MFA exigida: depois de matrícula+senha, aparece o campo de código e o painel NÃO abre. +testWidgets('pede o código do autenticador depois de matrícula e senha', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('totp_field')), findsOneWidget); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não abriu'); +}); + +// 2. Código certo: entra, e o app reenviou matrícula+senha COM o código. +testWidgets('com o código certo, entra', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(backend.lastTotpCode, '123456'); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); +}); + +// 3. Código errado: erro visível, painel fechado, campo continua. +testWidgets('com o código errado, mostra o erro e fica no login', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '000000'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.byKey(const Key('totp_field')), findsOneWidget); +}); + +// 4. Servidor exige MFA e o ACS não a tem: vai para a tela de ativação, com segredo e campo de código. +testWidgets('sem MFA ativada, leva à ativação e confirma com o código', (tester) async { + final backend = FakeAcsBackend()..mfaEnrollmentRequired = true; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.textContaining('GEZDGNBVGY3TQOJQ'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('mfa_code_field')), '654321'); + await tester.tap(find.byKey(const Key('mfa_confirm_button'))); + await tester.pumpAndSettle(); + + expect(backend.confirmedCode, '654321'); + // Voltou ao login, pronto para entrar de novo (agora com MFA). + expect(find.byKey(const Key('login_button')), findsOneWidget); +}); + +// 5. O código é só dígitos: o campo recusa letras e passa de 6. +testWidgets('o campo de código aceita só 6 dígitos', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '12ab34567'); + + final campo = tester.widget(find.byKey(const Key('totp_field'))); + expect(campo.controller!.text, '123456', reason: 'letras caem fora e o 7º dígito não entra'); + expect(campo.keyboardType, TextInputType.number); +}); +``` + +Run: `cd apps/acs && flutter test test/mfa_login_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`MfaCodeRequired`, `totpCode` não existem). + +- [ ] **Step 2: Implementar no cliente** + +`backend_client.dart`: `AcsBackend.login` ganha `String? totpCode`; acrescentar os dois métodos novos à interface, à classe que recusa tudo (`_recusar()`) e ao `BackendClient`; as duas falhas tipadas: + +```dart +/// O ACS tem MFA ativa e a senha conferiu: falta o código do autenticador. +class MfaCodeRequired extends BackendFailure { + const MfaCodeRequired() : super('Informe o código do aplicativo autenticador.'); +} + +/// O servidor exige MFA e este ACS ainda não a ativou. +class MfaEnrollmentRequired extends BackendFailure { + const MfaEnrollmentRequired() : super('Ative a verificação em duas etapas antes de entrar.', isRecoverable: false); +} +``` + +No `BackendClient.login`, repassar `totpCode:` a `_client.auth.loginInstitutional(...)`; e no `_guard` (onde `AuthenticationFailedException` vira `BackendFailure`, ~linha 411), antes dele: + +```dart + } on MfaRequiredException { + throw const MfaCodeRequired(); + } on MfaEnrollmentRequiredException { + throw const MfaEnrollmentRequired(); +``` + +`beginTotpEnrollment`/`confirmTotpEnrollment` chamam `_client.auth.beginTotpEnrollment(matricula:, password:)` / `confirmTotpEnrollment(matricula:, password:, code:)` **sem** `_requireToken()` (não há token) e dentro de `_guard`. A credencial segue só em memória (`_credentials`): nada novo é gravado em disco. + +- [ ] **Step 3: Implementar a tela de login e a de ativação** + +`_LoginScreenState` (`app.dart` ~161): estado `bool _pedeCodigo = false;` e `final _totp = TextEditingController();` (descartar no `dispose`); no tratamento da falha do `_enter`: + +```dart + } on MfaCodeRequired { + if (!mounted) return; + setState(() { _pedeCodigo = true; _loginError = null; _entering = false; }); + } on MfaEnrollmentRequired { + if (!mounted) return; + setState(() => _entering = false); + final ativou = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MfaEnrollmentScreen( + backend: widget.backend, + matricula: _matricula.text.trim(), + senha: _senha.text, + ), + )); + if (ativou == true && mounted) { + setState(() => _pedeCodigo = true); + } + return; + } +``` + +`_enter` passa `totpCode: _pedeCodigo ? _totp.text.trim() : null` ao `backend.login`. O campo (só quando `_pedeCodigo`), com os mesmos espaçamentos dos outros campos: + +```dart + if (_pedeCodigo) ...[ + const SizedBox(height: 12), + TextField( + key: const Key('totp_field'), + controller: _totp, + keyboardType: TextInputType.number, + maxLength: 6, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código do autenticador (6 dígitos)'), + onSubmitted: (_) => _enter(), + ), + ], +``` + +(`FilteringTextInputFormatter` vem de `package:flutter/services.dart`.) Se a pessoa **mudar** matrícula ou senha com o campo aberto, `setState(() => _pedeCodigo = false)` e limpar `_totp` — o código pertence à sessão que o pediu. + +Criar `lib/app/mfa_enrollment_screen.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show TotpEnrollmentStart; + +/// Ativação da verificação em duas etapas (RF07 / LGPD-RT06). +/// +/// Sem token: o ACS acabou de provar matrícula e senha. O segredo aparece só +/// aqui, em QR e em texto, e **não** é gravado no aparelho. +class MfaEnrollmentScreen extends StatefulWidget { + const MfaEnrollmentScreen({ + super.key, + required this.backend, + required this.matricula, + required this.senha, + }); + + final AcsBackend backend; + final String matricula; + final String senha; + + @override + State createState() => _MfaEnrollmentScreenState(); +} + +class _MfaEnrollmentScreenState extends State { + final _codigo = TextEditingController(); + TotpEnrollmentStart? _inicio; + String? _erro; + bool _ocupado = false; + + @override + void initState() { + super.initState(); + _iniciar(); + } + + @override + void dispose() { + _codigo.dispose(); + super.dispose(); + } + + Future _iniciar() async { + try { + final inicio = await widget.backend.beginTotpEnrollment(matricula: widget.matricula, senha: widget.senha); + if (mounted) setState(() => _inicio = inicio); + } on BackendFailure catch (falha) { + if (mounted) setState(() => _erro = falha.message); + } + } + + Future _confirmar() async { + if (_ocupado) return; + setState(() { _ocupado = true; _erro = null; }); + try { + await widget.backend.confirmTotpEnrollment( + matricula: widget.matricula, + senha: widget.senha, + code: _codigo.text.trim(), + ); + if (mounted) Navigator.of(context).pop(true); + } on BackendFailure catch (falha) { + if (mounted) setState(() { _erro = falha.message; _ocupado = false; }); + } + } + + @override + Widget build(BuildContext context) { + final inicio = _inicio; + return Scaffold( + appBar: AppBar(title: const Text('Verificação em duas etapas')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text('Leia o QR no aplicativo autenticador (ou digite a chave) e informe o código de 6 dígitos que ele mostrar.'), + const SizedBox(height: 16), + if (inicio == null && _erro == null) const Center(child: CircularProgressIndicator()), + if (inicio != null) ...[ + Center(child: QrImageView(data: inicio.otpauthUri, size: 200, backgroundColor: Colors.white)), + const SizedBox(height: 12), + SelectableText(inicio.secretBase32, key: const Key('mfa_secret')), + ], + if (_erro != null) + Semantics(liveRegion: true, child: Text(_erro!, key: const Key('mfa_error'), + style: TextStyle(color: Theme.of(context).colorScheme.error))), + const SizedBox(height: 16), + TextField( + key: const Key('mfa_code_field'), + controller: _codigo, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código de 6 dígitos'), + ), + const SizedBox(height: 12), + FilledButton( + key: const Key('mfa_confirm_button'), + onPressed: inicio == null || _ocupado ? null : _confirmar, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Ativar'), + ), + ], + ), + ); + } +} +``` + +Importar a tela em `app.dart` (`import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart';`). O QR é **branco com módulos escuros** por exigência de leitura (`invite_screen.dart` faz igual): não usar token de tema para ele. + +Run: `cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test test/mfa_login_test.dart 2>&1 | tail -3` +Expected: `No issues found!`; `All tests passed!` (5 testes). Depois a suíte inteira: `flutter test 2>&1 | tail -2` com **≥ 247** (242 + 5); os testes de login que chamam `login(...)` sem `totpCode` seguem verdes (parâmetro opcional). + +- [ ] **Step 2b: Teste de fonte ampliada nas telas novas** + +Em `test/text_scale_test.dart`, acrescentar caso a 130% e 200% que abre `MfaEnrollmentScreen` (com o `FakeAcsBackend`) em 360x800 e usa `percorrerTelaInteira` do harness da Task 1 anterior (`test/support/layout_harness.dart`), e outro com o campo de código aberto no login. Se estourar, corrigir com `Flexible`/rolagem como no plano anterior; sem `withClampedTextScaling`. + +- [ ] **Step 4: Gerador de código para os testes (RFC 6238, com os mesmos vetores)** + +Criar `apps/acs/test/support/totp.dart` e copiar byte a byte para `apps/acs/integration_test/support/totp.dart` (o `integration_test` não importa de `test/`): + +```dart +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} +``` + +Garantir `crypto` em `apps/acs/pubspec.yaml` (`dependencies:` — já está: `crypto: ^3.0.0`). Teste `test/totp_support_test.dart`: `totpCode(ascii('12345678901234567890'), DateTime.fromMillisecondsSinceEpoch(59000, isUtc: true)) == '287082'` e `base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ')` igual aos 20 bytes ASCII. Run: `flutter test test/totp_support_test.dart 2>&1 | tail -2` → `All tests passed!`. + +- [ ] **Step 5: Prova no emulador, contra o servidor real, pela tela** + +O relé entrega a credencial **uma vez** (memorizada por `acsCredentialFromRelay`) e a ativação persiste no banco da execução: este teste vai **por último** em `integration_test/full_journey_e2e.dart`, depois de todos os que fazem login só com a senha. O compose de e2e mantém `REQUIRE_ACS_MFA: "false"` (os outros testes entram só com a senha), então o teste **liga a MFA pelo RPC** e só então prova o login **pela tela**, com o campo de código. A tela de ativação (`mfa_secret`) fica provada pelos testes de widget do Step 1; registre isso como `Ruling:` no ledger. (Imports a acrescentar: `support/totp.dart`.) + +```dart + testWidgets('MFA: com a verificação ativa, o login pela tela pede e aceita o código', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final cliente = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(cliente.close); + final cred = await acsCredentialFromRelay(); + + // 1) Liga a MFA pelo RPC: matrícula + senha + o código do segredo recém-sorteado. + final inicio = await cliente.auth.beginTotpEnrollment(matricula: cred.matricula, password: cred.senha); + final segredo = base32Decode(inicio.secretBase32); + await cliente.auth.confirmTotpEnrollment( + matricula: cred.matricula, + password: cred.senha, + code: totpCode(segredo, DateTime.now()), + ); + + // 2) Login pela tela: matrícula + senha → o app pede o código e o painel NÃO abre. + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('totp_field')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir sem o código'); + + // 3) O código da ativação já foi usado (replay barrado): entra com o do PASSO SEGUINTE, + // que a janela de ±1 aceita. + await tester.enterText( + find.byKey(const Key('totp_field')), + totpCode(segredo, DateTime.now().add(const Duration(seconds: 30))), + ); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); +``` + +Run: `./scripts/qa/acs_full_e2e.sh 2>&1 | grep -E "OK —|Some tests|Expected|Actual|rc="; docker compose up -d` +Expected: `OK — jornada completa do ACS contra o banco de teste` (reconstruir a imagem do servidor antes, como na Task 3). + +- [ ] **Step 6: Documentar e commitar** + +`PROGRESS.md`: "MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos"; remover "MFA" de "Continua aberto" e de "adiados". `apps/CLAUDE.md`: parágrafo curto do fluxo (campo de código no login, `MfaEnrollmentScreen`, `acsCredentialFromRelay`, código do passo seguinte no e2e). + +```bash +git add apps/acs docker-compose.e2e.yml PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): login com código TOTP e tela de ativação da verificação em duas etapas" +``` + +--- + +### Task 7: mTLS no broker — certificados de cliente e prova de recusa + +**Files:** +- Modify: `infra/docker/mosquitto/init.sh`, `infra/docker/mosquitto/mosquitto.conf` +- Create: `scripts/qa/mtls_invariants.sh` +- Modify: `docker-compose.yml`, `.env.example` +- Modify: `backend/sinalacs_server/lib/src/config/app_config.dart`, `backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart` +- Test: `backend/sinalacs_server/test/unit/app_config_test.dart` +- Modify: `spec/stack.md` (nota de mTLS), `backend/CLAUDE.md`, `CLAUDE.md` + +**Interfaces:** +- Consumes: CA de desenvolvimento em `infra/docker/mosquitto/runtime/certs/ca.{crt,key}`; usuários MQTT `backend` e `acs-area-12`. +- Produces: `runtime/certs/backend.{crt,key}` e `runtime/certs/acs-area-12.{crt,key}` (CN = usuário MQTT, `extendedKeyUsage=clientAuth`); variáveis `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` do backend; `AppConfig.mqttClientCertificatePath/mqttClientKeyPath`. + +- [ ] **Step 1: Escrever o verificador de mTLS (falha: o broker aceita cliente sem certificado)** + +Criar `scripts/qa/mtls_invariants.sh` e `chmod +x`: + +```bash +#!/usr/bin/env bash +# +# O broker MQTT exige certificado de cliente (mTLS) E mantém a senha. +# +# ./scripts/qa/mtls_invariants.sh +# +# Precisa da stack de desenvolvimento no ar (docker compose up) e do .env. +# Quatro casos, cada um uma publicação de `mosquitto_pub` na porta 8883: +# A) senha certa, SEM certificado de cliente -> RECUSADO +# B) senha certa + certificado assinado pela CA do broker -> ACEITO +# C) senha certa + certificado de OUTRA CA -> RECUSADO +# D) certificado certo + senha errada -> RECUSADO +# Nenhum segredo é impresso. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_BACKEND_PASSWORD:?exporte MQTT_BACKEND_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +certs="$repo_root/infra/docker/mosquitto/runtime/certs" +for f in ca.crt backend.crt backend.key; do + [[ -f "$certs/$f" ]] || { echo "erro: $certs/$f não existe (a stack subiu com o init.sh novo?)." >&2; exit 4; } +done + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# Certificado "de fora": CA própria e uma folha dela, com o mesmo CN de um usuário real. +openssl req -x509 -newkey rsa:2048 -nodes -days 2 -keyout "$tmp/fora-ca.key" -out "$tmp/fora-ca.crt" \ + -subj '/CN=ca-de-fora' >/dev/null 2>&1 +openssl req -newkey rsa:2048 -nodes -keyout "$tmp/fora.key" -out "$tmp/fora.csr" -subj '/CN=backend' >/dev/null 2>&1 +printf 'extendedKeyUsage=clientAuth\n' >"$tmp/fora.ext" +openssl x509 -req -days 2 -in "$tmp/fora.csr" -CA "$tmp/fora-ca.crt" -CAkey "$tmp/fora-ca.key" \ + -CAcreateserial -extfile "$tmp/fora.ext" -out "$tmp/fora.crt" >/dev/null 2>&1 +chmod 644 "$tmp"/* + +publicar() { # publicar [--cert X --key Y] (devolve o código de saída) + local senha="$1"; shift + timeout 20 docker run --rm --network host \ + -v "$certs:/c:ro" -v "$tmp:/f:ro" -e MQTT_PW="$senha" eclipse-mosquitto:2 \ + sh -c 'mosquitto_pub -h localhost -p 8883 --cafile /c/ca.crt -u backend -P "$MQTT_PW" \ + -t sinalacs/v1/mtls/probe -m x '"$*" >/dev/null 2>&1 +} + +falhas=0 +esperar() { # esperar + local quer="$1" nome="$2"; shift 2 + if "$@"; then got=aceito; else got=recusado; fi + if [[ "$got" == "$quer" ]]; then echo "ok: $nome ($got)"; else echo "FALHOU: $nome — esperava $quer, foi $got"; falhas=$((falhas + 1)); fi +} + +esperar recusado "A) senha certa, sem certificado de cliente" publicar "$MQTT_BACKEND_PASSWORD" +esperar aceito "B) senha certa + certificado da CA do broker" \ + publicar "$MQTT_BACKEND_PASSWORD" --cert /c/backend.crt --key /c/backend.key +esperar recusado "C) certificado de outra CA" \ + publicar "$MQTT_BACKEND_PASSWORD" --cert /f/fora.crt --key /f/fora.key +esperar recusado "D) certificado certo, senha errada" \ + publicar "senha-errada" --cert /c/backend.crt --key /c/backend.key + +[[ "$falhas" -eq 0 ]] && echo "OK — o broker exige certificado de cliente e mantém a senha" || { echo "$falhas falha(s)"; exit 1; } +``` + +- [ ] **Step 2: Ver falhar** + +Run: `./scripts/qa/mtls_invariants.sh; echo rc=$?` +Expected: `erro: .../runtime/certs/backend.crt não existe` e `rc=4` (o `init.sh` ainda não emite certificado de cliente). Esse é o vermelho do passo; depois do Step 3 o vermelho passa a ser o caso A aceito. + +- [ ] **Step 3: Emitir certificados de cliente no `init.sh`** + +Em `infra/docker/mosquitto/init.sh`, depois do bloco do certificado do servidor e **antes** de `rm -f "$password_file"`, acrescentar: + +```sh +client_days=397 + +# Certificado de CLIENTE (mTLS): CN = o usuário MQTT, assinado pela mesma CA do +# servidor, só para autenticação de cliente. Renova quando vence, quando a CA +# mudou (o `verify` falha) ou quando não existe — mesma regra do servidor. +issue_client() { + cn="$1" + if [ -f "$certs_dir/$cn.crt" ] && [ -f "$certs_dir/$cn.key" ] \ + && openssl x509 -in "$certs_dir/$cn.crt" -noout -checkend 86400 >/dev/null 2>&1 \ + && openssl verify -CAfile "$certs_dir/ca.crt" "$certs_dir/$cn.crt" >/dev/null 2>&1; then + return 0 + fi + echo "Gerando certificado de cliente para $cn..." + printf 'basicConstraints=CA:FALSE\nkeyUsage=digitalSignature\nextendedKeyUsage=clientAuth\n' \ + > "$certs_dir/$cn.ext" + openssl req -newkey rsa:2048 -nodes -keyout "$certs_dir/$cn.key" \ + -out "$certs_dir/$cn.csr" -subj "/CN=$cn" + openssl x509 -req -days "$client_days" -in "$certs_dir/$cn.csr" \ + -CA "$certs_dir/ca.crt" -CAkey "$certs_dir/ca.key" -CAcreateserial \ + -extfile "$certs_dir/$cn.ext" -out "$certs_dir/$cn.crt" + rm -f "$certs_dir/$cn.csr" "$certs_dir/$cn.ext" +} + +issue_client backend +issue_client acs-area-12 +``` + +(O `chmod 644 "$certs_dir"/*.crt "$certs_dir"/*.key` que já existe no fim cobre os arquivos novos; chave de cliente legível por todos é só o dev, como a do servidor.) + +Em `mosquitto.conf`, depois de `keyfile ...`, acrescentar: + +``` +# mTLS: o cliente precisa apresentar certificado assinado pela CA acima. A senha +# e o ACL continuam valendo (sem use_identity_as_username): cert + senha. +require_certificate true +``` + +- [ ] **Step 4: Backend apresenta o certificado** + +`app_config.dart`: campos `final String? mqttClientCertificatePath; final String? mqttClientKeyPath;` lidos de `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` (vazio = `null`); regra de boot, no mesmo lugar e estilo das outras validações: **só um dos dois** definido é erro (`StateError('MQTT_CLIENT_CERT_PATH e MQTT_CLIENT_KEY_PATH devem vir juntos')`) e, **fora de `development`** com `MQTT_USE_TLS=true`, os dois são obrigatórios. Testes em `test/unit/app_config_test.dart`: (1) só o cert → erro; (2) os dois em `development` → ok; (3) nenhum em `development` → ok; (4) nenhum em `production` com TLS → erro; (5) nenhum em `production` **sem** TLS → ok (o broker de teste do CI sem TLS). + +`mqtt_alert_dispatcher.dart` (~linha 61): + +```dart + if (_config.mqttUseTls && _config.mqttCaCertificatePath != null) { + final contexto = SecurityContext(withTrustedRoots: false) + ..setTrustedCertificates(_config.mqttCaCertificatePath!); + final cert = _config.mqttClientCertificatePath; + final chave = _config.mqttClientKeyPath; + if (cert != null && chave != null) { + contexto + ..useCertificateChain(cert) + ..usePrivateKey(chave); + } + client.securityContext = contexto; + } +``` + +(Mantenha o que o trecho original já faz além disso; só acrescente o cert de cliente.) + +`docker-compose.yml`, serviço do servidor: ao lado de onde `MQTT_CA_CERT_PATH` é definido e a CA montada, acrescentar `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` apontando para os arquivos `backend.crt`/`backend.key` **no mesmo diretório montado** onde está a CA (confira com `grep -n "MQTT_CA_CERT_PATH\|mosquitto/runtime\|/certs" docker-compose.yml`; se a CA vem por uma montagem de arquivo único, monte também os dois novos). `.env.example`: documentar as duas variáveis. Faça o mesmo no `docker-compose.e2e.yml` se ele tiver o próprio servidor com `MQTT_CA_CERT_PATH`. + +- [ ] **Step 5: Subir, ver o vermelho certo e o verde** + +Run: `cd backend/sinalacs_server && dart test test/unit/app_config_test.dart 2>&1 | tail -2; cd ../.. && docker compose up -d --build 2>&1 | tail -3; sleep 25; docker compose ps --format '{{.Name}} {{.Status}}' | grep -E "mosquitto|serverpod"; ./scripts/qa/mtls_invariants.sh; echo rc=$?` +Expected: `All tests passed!`; os dois containers `Up`/`healthy`; as quatro linhas `ok:` e `OK — o broker exige certificado de cliente e mantém a senha`, `rc=0`. **Para ver o vermelho de verdade**, antes de aplicar o `require_certificate`, rode o script com `init.sh` novo e `mosquitto.conf` antigo: o caso A sai `FALHOU — esperava recusado, foi aceito`. Se o backend não conectar ao broker depois do `require_certificate` (log `docker compose logs serverpod | grep -i mqtt`), os caminhos de `MQTT_CLIENT_*` não batem com a montagem: corrija a montagem, **nunca** desligue o `require_certificate`. + +- [ ] **Step 6: Alerta ponta a ponta no servidor (o backend publica, o broker entrega)** + +Run: `docker compose exec -T serverpod true 2>/dev/null; ./scripts/qa/e2e.sh --help 2>&1 | head -5` +Expected: a ajuda lista o modo `--emulator`. (A prova do alerta pelo broker com mTLS depende do app do ACS apresentar o certificado — Task 8. Aqui só se confere que o backend segue conectado: `docker compose logs --tail 20 serverpod | grep -i "mqtt"` sem `Connection refused`/`bad certificate`.) + +- [ ] **Step 7: Documentar e commitar** + +`backend/CLAUDE.md`: acrescentar `MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` à lista de variáveis MQTT e a regra de boot. `spec/stack.md`: nota "Broker com mTLS (2026-10-02): `require_certificate true`; certificado de cliente por usuário MQTT, emitido pela CA de desenvolvimento; senha e ACL mantidos; produção exige provisionamento por aparelho (aberto)". `CLAUDE.md` da raiz: trocar "mTLS on the broker (no client certificates)" da lista do que falta por "mTLS: o broker exige certificado de cliente (dev); falta o provisionamento por aparelho em produção". + +```bash +git add infra scripts/qa/mtls_invariants.sh docker-compose.yml docker-compose.e2e.yml .env.example backend spec CLAUDE.md +git commit -m "feat(infra): broker MQTT exige certificado de cliente (mTLS), mantendo senha e ACL; backend apresenta o seu" +``` + +--- + +### Task 8: mTLS — o app do ACS apresenta o certificado + +**Files:** +- Modify: `scripts/dev/sync_dev_ca.sh` +- Modify: `apps/acs/lib/core/network/backend_config.dart`, `apps/acs/lib/core/services/mqtt_secure_client.dart`, `apps/acs/lib/core/services/alert_feed.dart`, `apps/acs/lib/app/app.dart` (mensagem do banner), `apps/acs/pubspec.yaml`, `.gitignore` de `apps/acs` +- Modify: `apps/acs/android/app/build.gradle.kts` (guard da chave de dev), `scripts/qa/acs_release_signing.sh` +- Test: `apps/acs/test/alert_feed_test.dart`, `apps/acs/test/mqtt_secure_client_test.dart` +- Modify: `PROGRESS.md`, `apps/CLAUDE.md` + +**Interfaces:** +- Consumes (Task 7): `runtime/certs/acs-area-12.{crt,key}`; broker com `require_certificate true`. +- Produces: assets `apps/acs/assets/certs/acs_client.crt` e `acs_client.key` (gitignorados); `BackendConfig.mqttClientCertAsset`, `BackendConfig.mqttClientKeyAsset`; `SecureMqttConfig.clientCertificate/clientPrivateKey` (`Uint8List?`); `AlertFeedFailureKind.missingClientCertificate`; propriedade Gradle `sinalacs.allowDevClientKey`. + +- [ ] **Step 1: Ver o app ser recusado pelo broker (vermelho no emulador)** + +Run: `./scripts/qa/e2e.sh --emulator 2>&1 | tail -15` +Expected (com a stack da Task 7 de pé): os testes de `smoke_test.dart` e `red_alert_cycle_test.dart` **falham** no passo do MQTT (o banner `Sem central`/recusa do broker). É o vermelho de dispositivo: o ACS não apresenta certificado. Anote as linhas no ledger. (Se o script exigir `--full` ou outros flags, use os que o cabeçalho de `scripts/qa/e2e.sh` documenta para rodar `smoke` e `red_alert_cycle` no `emulator-5554`.) + +- [ ] **Step 2: Testes unitários do app (falham: não há certificado de cliente na config)** + +Em `apps/acs/test/mqtt_secure_client_test.dart`, acrescentar, no estilo do arquivo, um caso que monta `SecureMqttConfig` **com** `clientCertificate`/`clientPrivateKey` e outro **sem**, e afirma que a config guarda os dois (e que, sem os dois, ficam `null`); e um caso de `buildMqttSecurityContext(config)` (função de nível de arquivo que o Step 3 extrai do ponto onde `client.securityContext` é montado, linha ~290): com um par cert+chave **PEM gerado no teste por `openssl`** (`Process.run('openssl', [...])` num `Directory.systemTemp.createTempSync()`, pulado com `skip:` se `openssl` não existir) o contexto monta sem lançar; com bytes de lixo, lança `TlsException`. + +Em `apps/acs/test/alert_feed_test.dart`, acrescentar: `AlertFeedFailureKind.values` contém `missingClientCertificate`, e um `AlertFeedFailure(AlertFeedFailureKind.missingClientCertificate, ...)` tem `transient == false` por padrão. + +Run: `cd apps/acs && flutter test test/mqtt_secure_client_test.dart test/alert_feed_test.dart 2>&1 | tail -5` +Expected: **FAIL** de compilação (`clientCertificate`, `buildMqttSecurityContext`, `missingClientCertificate` não existem). + +- [ ] **Step 3: Implementar no app** + +`backend_config.dart`: constantes + +```dart + /// Certificado e chave de CLIENTE para o mTLS do broker (spec/stack.md). + /// **Só desenvolvimento**: a chave privada num asset serve para a stack local; em + /// produção o certificado é provisionado por aparelho (aberto). + static const mqttClientCertAsset = 'assets/certs/acs_client.crt'; + static const mqttClientKeyAsset = 'assets/certs/acs_client.key'; +``` + +`mqtt_secure_client.dart`: em `SecureMqttConfig`, campos opcionais `final Uint8List? clientCertificate;` e `final Uint8List? clientPrivateKey;`; extrair para uma função de nível de arquivo e usar no ponto da linha ~290: + +```dart +/// Contexto TLS do cliente MQTT: confia **só** na CA do projeto e, se houver +/// certificado de cliente, o apresenta (mTLS). Hostname segue verificado. +SecurityContext buildMqttSecurityContext(SecureMqttConfig config) { + final contexto = SecurityContext(withTrustedRoots: false) + ..setTrustedCertificatesBytes(config.caCertificate); + final cert = config.clientCertificate; + final chave = config.clientPrivateKey; + if (cert != null && chave != null) { + contexto + ..useCertificateChainBytes(cert) + ..usePrivateKeyBytes(chave); + } + return contexto; +} +``` + +(`client.securityContext = buildMqttSecurityContext(config);` no lugar do trecho antigo; mantenha o resto do `connect` como está.) + +`alert_feed.dart`: novo `AlertFeedFailureKind.missingClientCertificate` ("Falta o certificado de cliente do ACS nos assets (`scripts/dev/sync_dev_ca.sh`)"), carregado **depois** do da CA, com o mesmo padrão `try { rootBundle.load } catch → AlertFeedFailure(missingClientCertificate, title: 'Falta o certificado de cliente da central neste aplicativo.', detail: 'Sem ele o broker recusa a conexão.', cause: error)`, e passado a `SecureMqttConfig(clientCertificate: ..., clientPrivateKey: ...)`. Em `app.dart`, onde o banner mapeia `AlertFeedFailureKind` (`grep -n "AlertFeedFailureKind" apps/acs/lib`), tratar o novo valor igual a `missingCaAsset` (não transitório, precisa recompilar/sincronizar). + +`apps/acs/pubspec.yaml`: confirmar que `assets/certs/` está listado como diretório (se listar arquivos um a um, acrescentar `acs_client.crt` e `acs_client.key`). `apps/acs/.gitignore` (ou o `.gitignore` da raiz, onde `dev_ca.crt` já é ignorado — confira com `git check-ignore -v apps/acs/assets/certs/dev_ca.crt`): ignorar `acs_client.crt` e `acs_client.key` do mesmo jeito. + +`scripts/dev/sync_dev_ca.sh`: ao copiar a CA para os assets, copiar também `infra/docker/mosquitto/runtime/certs/acs-area-12.crt` → `apps/acs/assets/certs/acs_client.crt` e `acs-area-12.key` → `acs_client.key`, **com a mesma verificação** que o script já faz da CA (`openssl verify -CAfile ca.crt acs-area-12.crt`) e o mesmo padrão de temporário + `mv`; `chmod 600` na chave copiada. + +Run: `./scripts/dev/sync_dev_ca.sh && cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test 2>&1 | tail -2` +Expected: assets copiados; `No issues found!`; `All tests passed!` (≥ 250). + +- [ ] **Step 4: Prova no emulador: o alerta volta a chegar, agora com mTLS** + +Run: `./scripts/qa/e2e.sh --emulator 2>&1 | tail -12; ./scripts/qa/mtls_invariants.sh` +Expected: `smoke_test`/`red_alert_cycle_test` **passam** (o ACS conecta ao broker com certificado + senha e recebe o alerta); as quatro linhas do `mtls_invariants.sh` seguem `ok:`. Conferir no log do broker que o cliente do app autenticou como `acs-area-12`: `docker compose logs mosquitto --tail 40 | grep -i "acs-area-12"`. + +- [ ] **Step 5: Release não leva a chave privada de desenvolvimento** + +Em `build.gradle.kts`, **dentro** do `gradle.taskGraph.whenReady` da Task 2, depois do guard de assinatura, acrescentar: + +```kotlin + // A chave privada de CLIENTE do broker de desenvolvimento (assets/certs/acs_client.key) + // não pode ir dentro de um APK de release: qualquer pessoa com o APK a extrairia. + val chaveDeDev = rootProject.file("../assets/certs/acs_client.key") + if (buildaRelease && chaveDeDev.exists() && !project.hasProperty("sinalacs.allowDevClientKey")) { + throw GradleException( + """ + |O release levaria a chave privada de desenvolvimento do broker (assets/certs/acs_client.key). + | + |Remova o arquivo antes de gerar o release (em produção o certificado de cliente é + |provisionado por aparelho — ainda não implementado). Para um release de TESTE LOCAL, + |acrescente -Psinalacs.allowDevClientKey=true. + """.trimMargin() + ) + } +``` + +Em `scripts/qa/acs_release_signing.sh`, os cenários 2 e 3 passam `-Psinalacs.allowDevClientKey=true`, e um **cenário 4** novo prova o guard: com a chave de dev presente (rode `sync_dev_ca.sh` antes ou crie um arquivo vazio descartável em `assets/certs/acs_client.key` e o remova no `trap`), `construir -Psinalacs.allowDebugSigning=true` **falha** com `chave privada de desenvolvimento`. + +Run: `./scripts/qa/acs_release_signing.sh; echo rc=$?` +Expected: quatro cenários e `OK — ...`, `rc=0`. (Para ver o vermelho: antes de acrescentar o guard, o cenário 4 sai `erro: o release passou levando a chave de desenvolvimento`.) + +- [ ] **Step 6: Documentar e commitar** + +`PROGRESS.md`: "mTLS do broker entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de dev), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); release com a chave de dev é barrado." Remover "mTLS" da lista de pendências do ACS. `apps/CLAUDE.md`: parágrafo curto (assets, `sync_dev_ca.sh`, `buildMqttSecurityContext`, guard do release). + +```bash +git add scripts apps/acs PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): app apresenta certificado de cliente ao broker (mTLS); release barra a chave de desenvolvimento" +``` + +--- + +### Task 9: Registro final e barra no emulador + +**Files:** +- Modify: `PROGRESS.md`, `CLAUDE.md` (raiz: a frase "Still missing: MFA and refresh token…, mTLS on the broker…, a backend for the admin backoffice…") + +**Interfaces:** nenhuma. + +- [ ] **Step 1: Atualizar o estado do projeto** + +No `CLAUDE.md` da raiz, parágrafo "Project overview", reescrever "Still missing" para: refresh token, provisionamento de certificado por aparelho em produção (o broker já exige certificado de cliente na stack local), backend do backoffice, deploy de produção; e acrescentar ao "Already delivered": MFA TOTP do ACS, RF08 persistido, `FLAG_SECURE`, release assinado com chave própria, botão da UBS. Em `PROGRESS.md`, a seção "Pendências do ACS (2026-10-02)" com uma linha por tarefa (commit, prova, o que ficou aberto) e **o que foi decidido** (D1–D8 resumidos). + +- [ ] **Step 2: Barra final** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin" +(cd backend/sinalacs_server && dart analyze && dart test 2>&1 | tail -2) +(cd apps/acs && flutter analyze && flutter test 2>&1 | tail -2) +python3 scripts/qa/contagem_validation_report.py +./scripts/qa/lib_rele_test.sh && ./scripts/qa/acs_gps_e2e_test.sh +./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh +docker compose up -d --build && sleep 25 && ./scripts/qa/mtls_invariants.sh +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio +./scripts/qa/acs_secure_window.sh && ./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao +./scripts/qa/e2e.sh --emulator +./scripts/qa/acs_release_signing.sh +./scripts/qa/acs_full_e2e.sh; docker compose up -d +``` + +Expected: `No issues found!` nos dois; suíte do backend e `flutter test` **todos verdes** (o ACS com ≥ 250 testes); conferidor da contagem `ok`; `ok: lib_rele` e `ok: acs_gps_e2e`; `ci_invariants` `ok`; links sem erro; `OK — o broker exige certificado de cliente e mantém a senha`; `OK — janela do ACS com FLAG_SECURE`; os dois GPS `OK`; o e2e do emulador verde com mTLS; `OK — release exige chave própria...`; jornada `OK` (com as linhas `rajada:` e a prova de RF08/MFA); `sinalacs-serverpod … (healthy)`; `ls .e2e/fixtures.json` → inexistente; nenhum app do ACS instalado no fim. + +Se `ci_invariants.sh` apontar o CI (por exemplo, o job `serverpod-backend` precisar de `REQUIRE_ACS_MFA` ou dos caminhos `MQTT_CLIENT_*` para o broker de teste), corrigir `.github/workflows/ci.yml` **mantendo** as invariantes (runner `ubuntu-24.04`, sem `paths`, concurrency) e registrar a mudança no ledger. + +- [ ] **Step 3: Estado do repositório e commit** + +```bash +git status --short && git log --oneline -12 && git log -12 --format=%B | grep -ci "co-authored\|generated with" +git add PROGRESS.md CLAUDE.md +git commit -m "docs: registra o fechamento das pendências do ACS (FLAG_SECURE, assinatura, UBS, RF08, MFA, mTLS)" +``` + +Expected: árvore limpa depois do commit; **`0`** na contagem de atribuições de IA; sem push. + +--- + +## Auto-revisão + +**Cobertura:** FLAG_SECURE → Task 1; assinatura de release → Task 2 (e o guard da chave de dev na Task 8); botão da UBS → Task 3; RF08 → Task 4 (com a decisão de LGPD escrita **antes** do código, como o `PROGRESS.md` exigia); MFA → Tasks 5 (backend) e 6 (app + emulador); mTLS → Tasks 7 (broker + backend) e 8 (app + emulador). A barra final e o registro estão na Task 9. O que **não** entra (refresh token, redefinição de MFA, provisionamento de certificado por aparelho, backoffice) tem o motivo escrito e vira "aberto" no `PROGRESS.md`, sem fingir fechamento. + +**Placeholders:** não há "TBD". Três pontos dependem de **ler um arquivo que esta sessão não abriu por inteiro** e dizem exatamente o quê: o corpo do `login` institucional (Task 5, Step 5 — com refatoração guardada pelos testes existentes), a sintaxe de escrita do store do ORM (Task 5, Step 6) e a montagem dos certificados no `docker-compose.yml` (Task 7, Step 4). Cada um tem o teste que prova o resultado. + +**Consistência de nomes:** `MicroAreaDirectory.load()`/`MicroAreaSnapshot`/`MicroAreaCacheStore.read/write/clear` iguais nas Tasks 4 (store, serviço, telas e e2e); `TotpEnrollment{sealed,enabled,lastStep}`, `TotpStore.saveSecret/enable/registerStep`, `SealedSecret`, `TotpSecretVault.seal/open` iguais entre o teste e a implementação da Task 5; `AcsBackend.login(..., totpCode)`, `MfaCodeRequired`/`MfaEnrollmentRequired`, chaves `totp_field`/`mfa_secret`/`mfa_code_field`/`mfa_confirm_button` iguais entre os testes e as telas da Task 6; `sinalacs.allowDebugSigning` (Task 2) e `sinalacs.allowDevClientKey` (Task 8) usados nos mesmos scripts. + +**Review Focus:** as 5 linhas têm teste dono (replay e tentativa errada na Task 5; cache de outro dono e vencido na Task 4; recusa sem certificado e de outra CA na Task 7; release sem chave na Task 2 e com a chave de dev na Task 8). + +**Riscos que só a execução resolve:** (1) o formato do `dumpsys window windows` no Android 16 (Task 1 manda ajustar o `awk` até ver o vermelho certo); (2) se `-P` chega ao Gradle pelo `flutter build` (Task 2 traz a alternativa `--android-project-arg`); (3) a refatoração do `login` (Task 5) mexe em código de segurança — os testes antigos são a rede; (4) se o `acs_full_e2e.sh` reconstrói a imagem do servidor (Tasks 3, 5 e 6 mandam conferir); (5) se o `eclipse-mosquitto:2` com `--network host` alcança `localhost:8883` neste host (Task 7); (6) a base do aparelho estar mesmo cifrada no caminho do cache (Task 4, Step 10 trata como achado de segurança se não estiver). + +**Escopo:** este plano junta seis frentes independentes (a skill sugere um plano por subsistema). Cada Task termina em software testável por si e pode ser executada, commitada e revertida sozinha; a ordem só importa entre 5→6 (MFA) e 7→8 (mTLS), e a Task 8 altera o guard da Task 2. From 093052289249d1684f8649d5120d04946713cbe1 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 11:22:04 -0400 Subject: [PATCH 30/43] =?UTF-8?q?feat(acs):=20janela=20com=20FLAG=5FSECURE?= =?UTF-8?q?=20bloqueia=20captura=20de=20tela,=20grava=C3=A7=C3=A3o=20e=20m?= =?UTF-8?q?iniatura=20dos=20recentes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/CLAUDE.md | 2 +- .../com/prismrr/sinalacs/acs/MainActivity.kt | 16 ++++- apps/acs/test/secure_window_test.dart | 20 ++++++ scripts/qa/acs_secure_window.sh | 69 +++++++++++++++++++ spec/lgpd_design.md | 2 + 5 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 apps/acs/test/secure_window_test.dart create mode 100755 scripts/qa/acs_secure_window.sh diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index dd8b675f..ab255b23 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -46,7 +46,7 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. -**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. diff --git a/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt b/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt index 0f2f7772..e5a429bd 100644 --- a/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt +++ b/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt @@ -1,5 +1,19 @@ package br.com.prismrr.sinalacs.acs +import android.os.Bundle +import android.view.WindowManager import io.flutter.embedding.android.FlutterActivity -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // A lista da microárea mostra nome e condições crônicas. FLAG_SECURE + // bloqueia captura de tela, gravação e a miniatura nos apps recentes, + // na janela INTEIRA: Área, Visita e o seletor de paciente a exibem, e + // proteger tela por tela deixaria uma de fora. + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } +} diff --git a/apps/acs/test/secure_window_test.dart b/apps/acs/test/secure_window_test.dart new file mode 100644 index 00000000..9587c644 --- /dev/null +++ b/apps/acs/test/secure_window_test.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A lista da microárea mostra nome e condições crônicas (LGPD §5.6). Sem +/// `FLAG_SECURE`, o sistema deixa a tela ser capturada, gravada e reduzida a +/// miniatura nos "apps recentes" — dado de saúde fora do controle do app. +/// Este teste lê a `MainActivity` porque `flutter test` não tem janela +/// Android; a prova na janela de verdade é `scripts/qa/acs_secure_window.sh`. +void main() { + final fonte = File( + 'android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt', + ).readAsStringSync(); + + test('a janela do ACS é FLAG_SECURE (sem captura, gravação nem miniatura)', () { + expect(fonte, contains('override fun onCreate')); + expect(fonte, contains('WindowManager.LayoutParams.FLAG_SECURE')); + expect(fonte, contains('window.setFlags(')); + }); +} diff --git a/scripts/qa/acs_secure_window.sh b/scripts/qa/acs_secure_window.sh new file mode 100755 index 00000000..ca65e8e6 --- /dev/null +++ b/scripts/qa/acs_secure_window.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# A janela do app do ACS tem FLAG_SECURE no emulador-5554 (sem captura de tela, +# gravação nem miniatura nos recentes). A prova é a flag da janela segundo o +# WindowManager (`dumpsys window windows`, linha `fl=`), não a leitura do código. +# +# ./scripts/qa/acs_secure_window.sh [--reinstalar] +# +# Aborta (exit 3) se o app já está instalado: reinstalar APAGA a fila de visitas +# offline (SQLCipher) e a chave do Keystore do app de desenvolvimento. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +reinstalar=0 +for arg in "$@"; do + case "$arg" in + --reinstalar) reinstalar=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +instalado() { adb -s "$dev" shell pm list packages "$pkg" 2>/dev/null | grep -q "^package:$pkg\$"; } +if instalado && [[ "$reinstalar" -eq 0 ]]; then + echo "erro: $pkg já está instalado em $dev; reinstalar APAGA a fila offline e a chave do Keystore." >&2 + echo " Se for só o app de teste de uma execução anterior, rode com --reinstalar." >&2 + exit 3 +fi + +log="$(mktemp)" +instalamos=0 +limpar() { + rm -f "$log" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT + +if ! (cd apps/acs && flutter build apk --debug \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" >"$log" 2>&1); then + echo "erro: o build do APK falhou. Últimas linhas:" >&2 + tail -n 30 "$log" >&2 + exit 1 +fi +if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +adb -s "$dev" install -r apps/acs/build/app/outputs/flutter-apk/app-debug.apk >/dev/null +instalamos=1 +adb -s "$dev" shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 +sleep 6 + +# Primeiro `fl=` do bloco da janela do app (a linha `pfl=` é de outra coisa). +flags="$(adb -s "$dev" shell dumpsys window windows | awk -v pkg="$pkg" ' + /^ Window #/ { dentro = index($0, pkg "/") > 0 } + dentro && /^ +fl=/ { print; exit }')" +if [[ -z "$flags" ]]; then + echo "erro: a janela de $pkg não apareceu em dumpsys window (o app abriu?)." >&2 + exit 4 +fi +if ! grep -qw SECURE <<<"$flags"; then + echo "erro: a janela do ACS não tem FLAG_SECURE: $flags" >&2 + exit 1 +fi +echo "OK — janela do ACS com FLAG_SECURE" diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index f1cd4724..43adb172 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -519,6 +519,8 @@ CPFs de documentação (como faz `seed_cpf_hashes.dart` para a stack de desenvol | **Limite conhecido** | Num aparelho comprometido (root) com o usuário autenticado, a chave é alcançável. Proteger contra isso exigiria o fator de posse do PIN. | | **Verificação** | `apps/acs/integration_test/encrypted_storage_test.dart` lê o arquivo do banco e afirma que ele não contém o conteúdo em texto plano. Roda em dispositivo — no CI (Linux) o caminho é o FFI, que não criptografa, e por isso a abertura fora de Android/iOS lança por padrão. | +**Captura de tela (2026-10-02).** A janela do app do ACS é `FLAG_SECURE`: o sistema não permite captura de tela, gravação nem miniatura nos apps recentes. Decisão: a janela inteira, porque a lista da microárea (nome e condições crônicas) aparece em mais de uma tela. Consequência aceita: `adb screencap` do ACS sai preto. Provado por `scripts/qa/acs_secure_window.sh`. + ### 5.2 Controle de Acesso por Perfil (RBAC) | Propriedade | Descrição | From e37bf5d87cf978c8fac1248f0bd79b6aea549a36 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 11:26:57 -0400 Subject: [PATCH 31/43] =?UTF-8?q?feat(acs):=20build=20de=20release=20exige?= =?UTF-8?q?=20chave=20pr=C3=B3pria;=20debug=20s=C3=B3=20com=20licen=C3=A7a?= =?UTF-8?q?=20expl=C3=ADcita?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/CLAUDE.md | 2 +- apps/acs/android/app/build.gradle.kts | 67 +++++++++++++++++++++++++-- scripts/qa/acs_release_signing.sh | 54 +++++++++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100755 scripts/qa/acs_release_signing.sh diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index ab255b23..4af9b00b 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -46,7 +46,7 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. -**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto). ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. diff --git a/apps/acs/android/app/build.gradle.kts b/apps/acs/android/app/build.gradle.kts index 72506f74..438e7b39 100644 --- a/apps/acs/android/app/build.gradle.kts +++ b/apps/acs/android/app/build.gradle.kts @@ -1,4 +1,6 @@ +import java.io.FileInputStream import java.util.Base64 +import java.util.Properties fun dartDefineValue(name: String): String? { val encoded = project.findProperty("dart-defines")?.toString() ?: return null @@ -9,6 +11,29 @@ fun dartDefineValue(name: String): String? { } } +// Chave de release: de `apps/acs/android/key.properties` (gitignorado) ou, para +// CI e shell, das variáveis SINALACS_KEYSTORE_*. Nenhum valor mora no repositório. +// storeFile=/caminho/para/release.jks +// storePassword=... +// keyAlias=... +// keyPassword=... +val keyProperties = Properties().apply { + val arquivo = rootProject.file("key.properties") + if (arquivo.exists()) FileInputStream(arquivo).use { load(it) } +} + +fun assinatura(propriedade: String, ambiente: String): String? = + keyProperties.getProperty(propriedade)?.takeIf { it.isNotBlank() } + ?: System.getenv(ambiente)?.takeIf { it.isNotBlank() } + +val releaseStoreFile = assinatura("storeFile", "SINALACS_KEYSTORE_PATH") +val releaseStorePassword = assinatura("storePassword", "SINALACS_KEYSTORE_PASSWORD") +val releaseKeyAlias = assinatura("keyAlias", "SINALACS_KEY_ALIAS") +val releaseKeyPassword = assinatura("keyPassword", "SINALACS_KEY_PASSWORD") +val temChaveDeRelease = listOf( + releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword, +).all { it != null } + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. @@ -37,11 +62,22 @@ android { manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartDefineValue("GOOGLE_MAPS_API_KEY") ?: "" } + signingConfigs { + if (temChaveDeRelease) { + create("release") { + storeFile = file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Sem chave, a configuração continua carregável (sync da IDE, debug): + // quem barra a build de RELEASE é o guard `taskGraph.whenReady` abaixo. + signingConfig = signingConfigs.getByName(if (temChaveDeRelease) "release" else "debug") } } } @@ -115,3 +151,28 @@ tasks.configureEach { ) } } + +// Guard de release: só quando uma tarefa de release está no grafo (o sync da IDE +// e `flutter run` em debug não passam por aqui). Sem chave de release, assinar +// com a de debug exige pedir (-Psinalacs.allowDebugSigning=true). +gradle.taskGraph.whenReady { + val buildaRelease = allTasks.any { + it.path.endsWith(":assembleRelease") || + it.path.endsWith(":bundleRelease") || + it.path.endsWith(":packageRelease") + } + if (buildaRelease && !temChaveDeRelease && !project.hasProperty("sinalacs.allowDebugSigning")) { + throw GradleException( + """ + |Sem chave de assinatura de release: esta build sairia assinada com a chave de debug. + | + |Informe a chave por apps/acs/android/key.properties (storeFile, storePassword, + |keyAlias, keyPassword) ou pelas variáveis SINALACS_KEYSTORE_PATH, + |SINALACS_KEYSTORE_PASSWORD, SINALACS_KEY_ALIAS e SINALACS_KEY_PASSWORD. + | + |Para assinar com a chave de debug DE PROPÓSITO (teste local, nunca distribuição): + | flutter build apk --release -Psinalacs.allowDebugSigning=true + """.trimMargin() + ) + } +} diff --git a/scripts/qa/acs_release_signing.sh b/scripts/qa/acs_release_signing.sh new file mode 100755 index 00000000..0aadb1c8 --- /dev/null +++ b/scripts/qa/acs_release_signing.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# A build de release do ACS não sai com a chave de debug sem pedir. +# +# ./scripts/qa/acs_release_signing.sh +# +# Três cenários, cada um uma build de release (~1 min): +# 1. sem chave de release e sem a licença de debug -> a build FALHA, dizendo por quê; +# 2. com -Psinalacs.allowDebugSigning=true -> assina com "Android Debug"; +# 3. com um keystore descartável (criado aqui, em /tmp) por variáveis de ambiente +# -> assina com o CN do keystore. +# Nenhum segredo real é tocado: o keystore do cenário 3 nasce e morre neste script. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +if [[ -f apps/acs/android/key.properties ]]; then + echo "erro: apps/acs/android/key.properties existe; ele mudaria o cenário 1. Mova-o para fora e rode de novo." >&2 + exit 3 +fi +apksigner="$(ls "$HOME"/Android/Sdk/build-tools/*/apksigner | tail -1)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +apk=apps/acs/build/app/outputs/flutter-apk/app-release.apk +define="--dart-define=SINALACS_MQTT_PASSWORD=$MQTT_ACS_PASSWORD" + +# O Gradle lê as variáveis SINALACS_KEYSTORE_* do ambiente; nos cenários 1 e 2 elas não existem. +construir() { (cd apps/acs && flutter build apk --release "$define" "$@"); } + +echo "== cenário 1: sem chave de release ==" +if saida="$( (unset SINALACS_KEYSTORE_PATH; construir) 2>&1)"; then + echo "erro: a build de release passou sem chave de release." >&2 + exit 1 +fi +grep -q "assinatura de release" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } + +echo "== cenário 2: -Psinalacs.allowDebugSigning=true ==" +construir -Psinalacs.allowDebugSigning=true >/dev/null +"$apksigner" verify --print-certs "$apk" | grep -q "CN=Android Debug" \ + || { echo "erro: o cenário 2 não assinou com a chave de debug." >&2; exit 1; } + +echo "== cenário 3: keystore descartável por variáveis de ambiente ==" +keytool -genkeypair -keystore "$tmp/teste.jks" -storepass senhateste -keypass senhateste \ + -alias teste -keyalg RSA -keysize 2048 -validity 2 -dname "CN=SinalACS Teste" >/dev/null 2>&1 +SINALACS_KEYSTORE_PATH="$tmp/teste.jks" SINALACS_KEYSTORE_PASSWORD=senhateste \ +SINALACS_KEY_ALIAS=teste SINALACS_KEY_PASSWORD=senhateste construir >/dev/null +"$apksigner" verify --print-certs "$apk" | grep -q "CN=SinalACS Teste" \ + || { echo "erro: o cenário 3 não assinou com o keystore informado." >&2; exit 1; } + +echo "OK — release exige chave própria; debug só com licença explícita" From b28f9be71181ea908c71766eedbbd9fd920e2b1e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 11:37:04 -0400 Subject: [PATCH 32/43] =?UTF-8?q?feat(acs):=20bot=C3=A3o=20da=20UBS=20liga?= =?UTF-8?q?=20para=20o=20telefone=20cadastrado=20(RF13)=20via=20ubs.myCont?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 4 +- apps/CLAUDE.md | 2 +- .../integration_test/full_journey_e2e.dart | 5 + apps/acs/lib/app/app.dart | 35 +- apps/acs/lib/core/network/backend_client.dart | 12 + apps/acs/test/escalation_dialer_test.dart | 9 - apps/acs/test/escalation_ubs_test.dart | 80 + apps/acs/test/support/fakes.dart | 16 + .../lib/src/protocol/api/ubs_contact.dart | 86 + .../lib/src/protocol/client.dart | 39 +- .../lib/src/protocol/protocol.dart | 560 +-- .../sinalacs_client/lib/src/protocol/ubs.dart | 13 + .../bin/seed_e2e_fixtures.dart | 4 +- .../application/ubs/ubs_contact_service.dart | 43 + .../lib/src/endpoints/ubs_endpoint.dart | 19 + .../lib/src/generated/api/ubs_contact.dart | 96 + .../lib/src/generated/endpoints.dart | 49 +- .../lib/src/generated/protocol.dart | 618 +-- .../lib/src/generated/protocol.yaml | 2 + .../lib/src/generated/ubs.dart | 29 + .../database/orm_ubs_contact_store.dart | 18 + .../database/seeds/development.sql | 4 + .../lib/src/models/api/ubs_contact.spy.yaml | 6 + .../lib/src/models/ubs.spy.yaml | 3 + .../lib/src/runtime/alert_runtime.dart | 6 + .../20261002153125316/definition.json | 3423 +++++++++++++++++ .../20261002153125316/definition.sql | 715 ++++ .../20261002153125316/definition_project.json | 2087 ++++++++++ .../20261002153125316/migration.json | 32 + .../20261002153125316/migration.sql | 25 + .../migrations/migration_registry.txt | 1 + .../test_tools/serverpod_test_tools.dart | 63 +- .../test/unit/ubs_contact_service_test.dart | 76 + docs/roteiro-video-sponsor.md | 2 +- spec/lgpd_data_audit.md | 1 + spec/ux_accessibility_assessment.md | 2 +- spec/validation_report.md | 2 +- 37 files changed, 7570 insertions(+), 617 deletions(-) create mode 100644 apps/acs/test/escalation_ubs_test.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart create mode 100644 backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart create mode 100644 backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart create mode 100644 backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml create mode 100644 backend/sinalacs_server/migrations/20261002153125316/definition.json create mode 100644 backend/sinalacs_server/migrations/20261002153125316/definition.sql create mode 100644 backend/sinalacs_server/migrations/20261002153125316/definition_project.json create mode 100644 backend/sinalacs_server/migrations/20261002153125316/migration.json create mode 100644 backend/sinalacs_server/migrations/20261002153125316/migration.sql create mode 100644 backend/sinalacs_server/test/unit/ubs_contact_service_test.dart diff --git a/PROGRESS.md b/PROGRESS.md index 389146c7..02c52155 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -563,12 +563,12 @@ Atenção: o código atual já valida a operação crítica de alerta vermelho e Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `emulator-5554`. - **RF12 — permissão de localização.** O manifesto do ACS não declarava `ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION` (nem `INTERNET`, que só vinha de plugin): sem elas o GPS do check-in nunca chegava, e os testes passavam porque injetam a posição. Declaradas e guardadas por `test/android_manifest_test.dart`. `./scripts/qa/acs_gps_e2e.sh [--sem-permissao]` prova a permissão em runtime e falha com o manifesto antigo. **Não prova a chegada de um fix de GPS**: neste AVD (Android 16, Play Services) nem `adb emu geo fix`, nem provider de teste, nem `forceLocationManager` entregam posição ao app; GPS real em aparelho físico segue sem prova em dispositivo. -- **RF13 — SAMU.** O botão abre o discador com 192 (`core/services/emergency_dialer.dart`, `url_launcher`, `` para `tel:`), sem ligar sozinho e sem `CALL_PHONE`; sem discador mostra o número em texto; trava de toque duplo. "Encaminhar para UBS Central" segue sendo um aviso (sem fonte de contato da UBS: decisão de produto). +- **RF13 — SAMU.** O botão abre o discador com 192 (`core/services/emergency_dialer.dart`, `url_launcher`, `` para `tel:`), sem ligar sozinho e sem `CALL_PHONE`; sem discador mostra o número em texto; trava de toque duplo. "Ligar para a UBS" também abre o discador, com o telefone de `ubs.contactPhone` entregue por `ubs.myContact` (RPC do ACS; a UBS vem do token); sem telefone cadastrado, avisa e não liga. O cadastro do número cabe ao backoffice (ainda mock): hoje vale a seed. - **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. - **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). - **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). - **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, MFA/refresh token, iOS, botão da UBS. -- **Fora:** RF08 (cache persistido da microárea offline — exige desenho de LGPD antes), MFA/refresh token, iOS (`apps/acs/ios/` não existe), botão da UBS. +- **Fora:** RF08 (cache persistido da microárea offline — exige desenho de LGPD antes), MFA/refresh token, iOS (`apps/acs/ios/` não existe). - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Migração para Serverpod diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 4af9b00b..91ad10a9 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -46,7 +46,7 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **WCAG contrast tokens**: `app/acs_theme.dart`, `app/patient_theme.dart` and `app/admin_theme.dart` each carry a text-safe variant of every clinical fill color — `redOnSurface`/`accentOnSurface` (ACS), `dangerOnSurface`/`accentOnSurface` (patient), `redOnSurface`/`accentOnSurface` (admin). `red`/`accent`/`danger` only clear WCAG 1.4.3's 4.5:1 as a *fill* (e.g. white text on a red button); reused as *text* color on `surfaceRaised`/`Card` — where risk/status text is actually rendered — they drop to ~3:1. `acsOnSurface()`/`adminOnSurface()` (and the patient-app equivalent) convert fill → text color at the single point where a `switch (riskLevel)` used to feed both, instead of at every call site. The admin's `accentOnSurface` (`#818CF8`, indigo-400) is deliberately *not* the ACS's `#60A5FA`: the admin's fill is indigo `#4F46E5`, not the ACS's blue `#2563EB` — the shared rule across apps is "same color, -400 step," not a literal value, and copying the ACS constant would have passed contrast while clashing with the accent-colored border right next to it. `test/contrast_tokens_test.dart` in each app checks the full token×surface matrix by computing relative luminance directly (`test/support/contrast.dart`) rather than by eyeballing a contrast checker against the wrong surface — which is exactly the false positive/negative `spec/ux_accessibility_assessment.md` documents from the original manual audit. The same pass added `minimumSize` (48×52dp, 64×60dp for the SAMU emergency call) per WCAG 2.5.5 and `Semantics(liveRegion: true)` on dynamic status messages (login errors, feed/storage errors, rejected-visit counts) per WCAG 4.1.3. -**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto). +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. O botão "Ligar para a UBS" (RF13) usa o mesmo discador com o telefone que `AcsBackend.ubsContact()` busca em `ubs.myContact`; sem telefone cadastrado, avisa e não liga. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto). ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index db996328..298413ea 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -97,6 +97,11 @@ void main() { final daMicroarea = await acsClient.patients.listMicroArea(accessToken: acs.accessToken); expect(daMicroarea.any((p) => p.patientId == main.id), isTrue); expect(daMicroarea.any((p) => p.patientId == outsider.id), isFalse, reason: 'outra microárea'); + // RF13: o contato da UBS chega pelo servidor real, escopado pelo token do ACS. + final contato = await acsClient.ubs.myContact(accessToken: acs.accessToken); + expect(contato.name, 'UBS E2E'); + expect(contato.phone, '+55 11 5550-0199'); + final remotas = await acsClient.visits.pull( accessToken: acs.accessToken, since: DateTime.fromMillisecondsSinceEpoch(0), diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index d31c12eb..66d253ed 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -1953,6 +1953,33 @@ class _EscalationScreenState extends State { } } + /// Liga para a UBS do ACS (RF13). O telefone vem do servidor; sem ele, avisa. + Future _callUbs() async { + if (_dialing) return; + setState(() => _dialing = true); + String? aviso; + try { + final contato = await BackendScope.of(context).ubsContact(); + final telefone = contato.phone?.trim(); + if (telefone == null || telefone.isEmpty) { + aviso = '${contato.name} ainda não cadastrou um telefone. Acione a coordenação.'; + } else { + // O discador recebe só dígitos e `+`: espaço e hífen viram %20 no `tel:`. + final numero = telefone.replaceAll(RegExp(r'[^0-9+]'), ''); + if (!await widget.dialer.dial(numero)) { + aviso = 'Não foi possível abrir o discador. Ligue manualmente para $telefone.'; + } + } + } on BackendFailure catch (falha) { + aviso = falha.message; + } catch (_) { + aviso = 'Não foi possível obter o contato da UBS.'; + } + if (!mounted) return; + setState(() => _dialing = false); + if (aviso != null) _message(context, aviso); + } + @override Widget build(BuildContext context) { final current = widget.alert; @@ -1975,10 +2002,12 @@ class _EscalationScreenState extends State { label: const Text('Ligar para o SAMU (192)'), ), const SizedBox(height: 12), - OutlinedButton( - onPressed: () => _message(context, 'Encaminhamento será integrado à UBS.'), + OutlinedButton.icon( + key: const Key('escalation_ubs'), + onPressed: _dialing ? null : _callUbs, style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), - child: const Text('Encaminhar para UBS Central'), + icon: const Icon(Icons.local_hospital_outlined), + label: const Text('Ligar para a UBS'), ), if (current != null) ...[ const SizedBox(height: 12), diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index 288b0aab..bcaf17ab 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -81,6 +81,9 @@ abstract class AcsBackend { /// partir fora do caminho reativo. Future> listPatients(); + /// Contato da UBS do ACS (RF13): nome e telefone, que pode não estar cadastrado. + Future ubsContact(); + /// Convite de onboarding de um paciente da própria microárea (RF02). O /// token em claro volta só nesta resposta e vira o QR Code da tela /// "Convidar paciente" — nunca é gravado no aparelho. @@ -148,6 +151,9 @@ class MisconfiguredBackend implements AcsBackend { @override Future> listPatients() async => _recusar(); + @override + Future ubsContact() async => _recusar(); + @override Future generateInvite({required String patientId}) async => _recusar(); @@ -358,6 +364,12 @@ class BackendClient implements AcsBackend { ); } + @override + Future ubsContact() async { + final token = await _requireToken(); + return _guard(() => _client.ubs.myContact(accessToken: token)); + } + @override Future generateInvite({required String patientId}) async { final token = await _requireToken(); diff --git a/apps/acs/test/escalation_dialer_test.dart b/apps/acs/test/escalation_dialer_test.dart index bfd7b31b..e1b9939a 100644 --- a/apps/acs/test/escalation_dialer_test.dart +++ b/apps/acs/test/escalation_dialer_test.dart @@ -96,13 +96,4 @@ void main() { expect(dialed, ['192']); }); - - testWidgets('o botão da UBS continua sendo o aviso honesto (sem contato cadastrado)', (tester) async { - await _pump(tester, _FakeDialer()); - - await tester.tap(find.text('Encaminhar para UBS Central')); - await tester.pump(); - - expect(find.text('Encaminhamento será integrado à UBS.'), findsOneWidget); - }); } diff --git a/apps/acs/test/escalation_ubs_test.dart b/apps/acs/test/escalation_ubs_test.dart new file mode 100644 index 00000000..1bbc6c7c --- /dev/null +++ b/apps/acs/test/escalation_ubs_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show UbsContact; + +import 'support/fakes.dart'; + +class _Dialer implements EmergencyDialer { + _Dialer({this.abre = true}); + final bool abre; + final discados = []; + + @override + Future dial(String number) async { + discados.add(number); + return abre; + } +} + +Future _abrir(WidgetTester tester, FakeAcsBackend backend, _Dialer dialer) => + tester.pumpWidget(MaterialApp( + home: BackendScope( + backend: backend, + child: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + ), + )); + +void main() { + testWidgets('o botão da UBS abre o discador com o telefone cadastrado, só dígitos e +', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, ['+551155500100']); + expect(backend.ubsContactCount, 1); + }); + + testWidgets('UBS sem telefone: avisa e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactResult = UbsContact(name: 'UBS Sem Fone'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.textContaining('UBS Sem Fone ainda não cadastrou um telefone'), findsOneWidget); + }); + + testWidgets('sem discador, mostra o telefone em texto', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(abre: false); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Ligue manualmente para +55 11 5550-0100'), findsOneWidget); + }); + + testWidgets('falha ao obter o contato mostra a mensagem do backend e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactFailure = const BackendFailure('Sem conexão.'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.text('Sem conexão.'), findsOneWidget); + }); +} diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 4cd3cd0f..de358c0e 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -125,6 +125,22 @@ class FakeAcsBackend implements AcsBackend { ]; } + /// Contato que `ubsContact()` devolve; `UbsContact(name: ...)` sem telefone simula uma UBS sem número. + UbsContact ubsContactResult = UbsContact(name: 'UBS Teste', phone: '+55 11 5550-0100'); + + /// Falha da chamada, como uma queda de rede. + BackendFailure? ubsContactFailure; + + int ubsContactCount = 0; + + @override + Future ubsContact() async { + ubsContactCount++; + final falha = ubsContactFailure; + if (falha != null) throw falha; + return ubsContactResult; + } + /// Falha não classificada (não é `BackendFailure`), para exercitar o ramo /// `catch (error, ...)` genérico de `_loadMicroAreaPatients` em `app.dart` — /// algo que nenhum `BackendFailure` simula. Checada antes de diff --git a/backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart b/backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart new file mode 100644 index 00000000..3402ad83 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart @@ -0,0 +1,86 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +/// precisa: o nome para a mensagem e o telefone para o discador. +abstract class UbsContact implements _i1.SerializableModel { + UbsContact._({ + required this.name, + this.phone, + }); + + factory UbsContact({ + required String name, + String? phone, + }) = _UbsContactImpl; + + factory UbsContact.fromJson(Map jsonSerialization) { + return UbsContact( + name: jsonSerialization['name'] as String, + phone: jsonSerialization['phone'] as String?, + ); + } + + String name; + + String? phone; + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + UbsContact copyWith({ + String? name, + String? phone, + }); + @override + Map toJson() { + return { + '__className__': 'UbsContact', + 'name': name, + if (phone != null) 'phone': phone, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _UbsContactImpl extends UbsContact { + _UbsContactImpl({ + required String name, + String? phone, + }) : super._( + name: name, + phone: phone, + ); + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + UbsContact copyWith({ + String? name, + Object? phone = _Undefined, + }) { + return UbsContact( + name: name ?? this.name, + phone: phone is String? ? phone : this.phone, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 05ccb101..ef034f0e 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -39,10 +39,11 @@ import 'package:sinalacs_client/src/protocol/api/terms_change_notice.dart' import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' as _i16; import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i17; +import 'package:sinalacs_client/src/protocol/api/ubs_contact.dart' as _i18; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i18; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i19; -import 'protocol.dart' as _i20; + as _i19; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i20; +import 'protocol.dart' as _i21; /// Ciclo do alerta vermelho. /// @@ -539,6 +540,22 @@ class EndpointTriage extends EndpointAuthenticated { ); } +/// Contato da UBS do ACS (RF13). +/// {@category Endpoint} +class EndpointUbs extends EndpointAuthenticated { + EndpointUbs(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'ubs'; + + _i2.Future<_i18.UbsContact> myContact({required String accessToken}) => + caller.callServerEndpoint<_i18.UbsContact>( + 'ubs', + 'myContact', + {'accessToken': accessToken}, + ); +} + /// Sincronização das visitas domiciliares registradas offline. /// /// É a contraparte da fila offline do app do ACS: o dispositivo grava a visita @@ -555,10 +572,10 @@ class EndpointVisits extends EndpointAuthenticated { @override String get name => 'visits'; - _i2.Future> sync({ + _i2.Future> sync({ required String accessToken, - required List<_i19.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i20.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -570,10 +587,10 @@ class EndpointVisits extends EndpointAuthenticated { /// Sincronização central→dispositivo: visitas da microárea do ACS /// autenticado alteradas após `since`, para reconciliar um device que /// ficou offline ou foi reinstalado. - _i2.Future> pull({ + _i2.Future> pull({ required String accessToken, required DateTime since, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'visits', 'pull', { @@ -603,7 +620,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i20.Protocol(), + _i21.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, @@ -620,6 +637,7 @@ class Client extends _i1.ServerpodClientShared { onboarding = EndpointOnboarding(this); patients = EndpointPatients(this); triage = EndpointTriage(this); + ubs = EndpointUbs(this); visits = EndpointVisits(this); } @@ -639,6 +657,8 @@ class Client extends _i1.ServerpodClientShared { late final EndpointTriage triage; + late final EndpointUbs ubs; + late final EndpointVisits visits; @override @@ -651,6 +671,7 @@ class Client extends _i1.ServerpodClientShared { 'onboarding': onboarding, 'patients': patients, 'triage': triage, + 'ubs': ubs, 'visits': visits, }; diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 4d8b9cd2..14a0f754 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -32,44 +32,45 @@ import 'api/red_alert_result.dart' as _i18; import 'api/service_health.dart' as _i19; import 'api/terms_change_notice.dart' as _i20; import 'api/triage_result.dart' as _i21; -import 'api/visit_sync_entry.dart' as _i22; -import 'api/visit_sync_result.dart' as _i23; -import 'audit_log.dart' as _i24; -import 'consent_log.dart' as _i25; -import 'data_subject_request.dart' as _i26; -import 'enrollment_token.dart' as _i27; -import 'enums/alert_status.dart' as _i28; -import 'enums/arrival_method.dart' as _i29; -import 'enums/consent_purpose.dart' as _i30; -import 'enums/data_subject_request_status.dart' as _i31; -import 'enums/data_subject_request_type.dart' as _i32; -import 'enums/risk_level.dart' as _i33; -import 'enums/sync_status.dart' as _i34; -import 'enums/user_role.dart' as _i35; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i36; -import 'exceptions/alert_permission_exception.dart' as _i37; -import 'exceptions/alert_validation_exception.dart' as _i38; -import 'exceptions/authentication_failed_exception.dart' as _i39; -import 'exceptions/data_rights_exception.dart' as _i40; -import 'exceptions/endpoint_disabled_exception.dart' as _i41; -import 'exceptions/enrollment_exception.dart' as _i42; -import 'exceptions/notice_delivery_exception.dart' as _i43; -import 'exceptions/otp_request_exception.dart' as _i44; -import 'micro_area.dart' as _i45; -import 'otp_challenge.dart' as _i46; -import 'patient.dart' as _i47; -import 'push_token.dart' as _i48; -import 'triage_answer.dart' as _i49; -import 'triage_session.dart' as _i50; -import 'ubs.dart' as _i51; -import 'user.dart' as _i52; -import 'user_credential.dart' as _i53; -import 'visit.dart' as _i54; +import 'api/ubs_contact.dart' as _i22; +import 'api/visit_sync_entry.dart' as _i23; +import 'api/visit_sync_result.dart' as _i24; +import 'audit_log.dart' as _i25; +import 'consent_log.dart' as _i26; +import 'data_subject_request.dart' as _i27; +import 'enrollment_token.dart' as _i28; +import 'enums/alert_status.dart' as _i29; +import 'enums/arrival_method.dart' as _i30; +import 'enums/consent_purpose.dart' as _i31; +import 'enums/data_subject_request_status.dart' as _i32; +import 'enums/data_subject_request_type.dart' as _i33; +import 'enums/risk_level.dart' as _i34; +import 'enums/sync_status.dart' as _i35; +import 'enums/user_role.dart' as _i36; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i37; +import 'exceptions/alert_permission_exception.dart' as _i38; +import 'exceptions/alert_validation_exception.dart' as _i39; +import 'exceptions/authentication_failed_exception.dart' as _i40; +import 'exceptions/data_rights_exception.dart' as _i41; +import 'exceptions/endpoint_disabled_exception.dart' as _i42; +import 'exceptions/enrollment_exception.dart' as _i43; +import 'exceptions/notice_delivery_exception.dart' as _i44; +import 'exceptions/otp_request_exception.dart' as _i45; +import 'micro_area.dart' as _i46; +import 'otp_challenge.dart' as _i47; +import 'patient.dart' as _i48; +import 'push_token.dart' as _i49; +import 'triage_answer.dart' as _i50; +import 'triage_session.dart' as _i51; +import 'ubs.dart' as _i52; +import 'user.dart' as _i53; +import 'user_credential.dart' as _i54; +import 'visit.dart' as _i55; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i55; -import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i56; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i57; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' + as _i57; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i58; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -90,6 +91,7 @@ export 'api/red_alert_result.dart'; export 'api/service_health.dart'; export 'api/terms_change_notice.dart'; export 'api/triage_result.dart'; +export 'api/ubs_contact.dart'; export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.dart'; export 'audit_log.dart'; @@ -219,104 +221,107 @@ class Protocol extends _i1.SerializationManager { if (t == _i21.TriageResult) { return _i21.TriageResult.fromJson(data) as T; } - if (t == _i22.VisitSyncEntry) { - return _i22.VisitSyncEntry.fromJson(data) as T; + if (t == _i22.UbsContact) { + return _i22.UbsContact.fromJson(data) as T; + } + if (t == _i23.VisitSyncEntry) { + return _i23.VisitSyncEntry.fromJson(data) as T; } - if (t == _i23.VisitSyncResult) { - return _i23.VisitSyncResult.fromJson(data) as T; + if (t == _i24.VisitSyncResult) { + return _i24.VisitSyncResult.fromJson(data) as T; } - if (t == _i24.AuditLog) { - return _i24.AuditLog.fromJson(data) as T; + if (t == _i25.AuditLog) { + return _i25.AuditLog.fromJson(data) as T; } - if (t == _i25.ConsentLog) { - return _i25.ConsentLog.fromJson(data) as T; + if (t == _i26.ConsentLog) { + return _i26.ConsentLog.fromJson(data) as T; } - if (t == _i26.DataSubjectRequest) { - return _i26.DataSubjectRequest.fromJson(data) as T; + if (t == _i27.DataSubjectRequest) { + return _i27.DataSubjectRequest.fromJson(data) as T; } - if (t == _i27.EnrollmentToken) { - return _i27.EnrollmentToken.fromJson(data) as T; + if (t == _i28.EnrollmentToken) { + return _i28.EnrollmentToken.fromJson(data) as T; } - if (t == _i28.AlertStatus) { - return _i28.AlertStatus.fromJson(data) as T; + if (t == _i29.AlertStatus) { + return _i29.AlertStatus.fromJson(data) as T; } - if (t == _i29.ArrivalMethod) { - return _i29.ArrivalMethod.fromJson(data) as T; + if (t == _i30.ArrivalMethod) { + return _i30.ArrivalMethod.fromJson(data) as T; } - if (t == _i30.ConsentPurpose) { - return _i30.ConsentPurpose.fromJson(data) as T; + if (t == _i31.ConsentPurpose) { + return _i31.ConsentPurpose.fromJson(data) as T; } - if (t == _i31.DataSubjectRequestStatus) { - return _i31.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i32.DataSubjectRequestStatus) { + return _i32.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i32.DataSubjectRequestType) { - return _i32.DataSubjectRequestType.fromJson(data) as T; + if (t == _i33.DataSubjectRequestType) { + return _i33.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i33.RiskLevel) { - return _i33.RiskLevel.fromJson(data) as T; + if (t == _i34.RiskLevel) { + return _i34.RiskLevel.fromJson(data) as T; } - if (t == _i34.SyncStatus) { - return _i34.SyncStatus.fromJson(data) as T; + if (t == _i35.SyncStatus) { + return _i35.SyncStatus.fromJson(data) as T; } - if (t == _i35.UserRole) { - return _i35.UserRole.fromJson(data) as T; + if (t == _i36.UserRole) { + return _i36.UserRole.fromJson(data) as T; } - if (t == _i36.AlertDispatchUnavailableException) { - return _i36.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i37.AlertDispatchUnavailableException) { + return _i37.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i37.AlertPermissionException) { - return _i37.AlertPermissionException.fromJson(data) as T; + if (t == _i38.AlertPermissionException) { + return _i38.AlertPermissionException.fromJson(data) as T; } - if (t == _i38.AlertValidationException) { - return _i38.AlertValidationException.fromJson(data) as T; + if (t == _i39.AlertValidationException) { + return _i39.AlertValidationException.fromJson(data) as T; } - if (t == _i39.AuthenticationFailedException) { - return _i39.AuthenticationFailedException.fromJson(data) as T; + if (t == _i40.AuthenticationFailedException) { + return _i40.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i40.DataRightsException) { - return _i40.DataRightsException.fromJson(data) as T; + if (t == _i41.DataRightsException) { + return _i41.DataRightsException.fromJson(data) as T; } - if (t == _i41.EndpointDisabledException) { - return _i41.EndpointDisabledException.fromJson(data) as T; + if (t == _i42.EndpointDisabledException) { + return _i42.EndpointDisabledException.fromJson(data) as T; } - if (t == _i42.EnrollmentException) { - return _i42.EnrollmentException.fromJson(data) as T; + if (t == _i43.EnrollmentException) { + return _i43.EnrollmentException.fromJson(data) as T; } - if (t == _i43.NoticeDeliveryException) { - return _i43.NoticeDeliveryException.fromJson(data) as T; + if (t == _i44.NoticeDeliveryException) { + return _i44.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i44.OtpRequestException) { - return _i44.OtpRequestException.fromJson(data) as T; + if (t == _i45.OtpRequestException) { + return _i45.OtpRequestException.fromJson(data) as T; } - if (t == _i45.MicroArea) { - return _i45.MicroArea.fromJson(data) as T; + if (t == _i46.MicroArea) { + return _i46.MicroArea.fromJson(data) as T; } - if (t == _i46.OtpChallenge) { - return _i46.OtpChallenge.fromJson(data) as T; + if (t == _i47.OtpChallenge) { + return _i47.OtpChallenge.fromJson(data) as T; } - if (t == _i47.Patient) { - return _i47.Patient.fromJson(data) as T; + if (t == _i48.Patient) { + return _i48.Patient.fromJson(data) as T; } - if (t == _i48.PushToken) { - return _i48.PushToken.fromJson(data) as T; + if (t == _i49.PushToken) { + return _i49.PushToken.fromJson(data) as T; } - if (t == _i49.TriageAnswer) { - return _i49.TriageAnswer.fromJson(data) as T; + if (t == _i50.TriageAnswer) { + return _i50.TriageAnswer.fromJson(data) as T; } - if (t == _i50.TriageSession) { - return _i50.TriageSession.fromJson(data) as T; + if (t == _i51.TriageSession) { + return _i51.TriageSession.fromJson(data) as T; } - if (t == _i51.Ubs) { - return _i51.Ubs.fromJson(data) as T; + if (t == _i52.Ubs) { + return _i52.Ubs.fromJson(data) as T; } - if (t == _i52.User) { - return _i52.User.fromJson(data) as T; + if (t == _i53.User) { + return _i53.User.fromJson(data) as T; } - if (t == _i53.UserCredential) { - return _i53.UserCredential.fromJson(data) as T; + if (t == _i54.UserCredential) { + return _i54.UserCredential.fromJson(data) as T; } - if (t == _i54.Visit) { - return _i54.Visit.fromJson(data) as T; + if (t == _i55.Visit) { + return _i55.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -387,128 +392,131 @@ class Protocol extends _i1.SerializationManager { if (t == _i1.getType<_i21.TriageResult?>()) { return (data != null ? _i21.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.VisitSyncEntry?>()) { - return (data != null ? _i22.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i22.UbsContact?>()) { + return (data != null ? _i22.UbsContact.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.VisitSyncResult?>()) { - return (data != null ? _i23.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.VisitSyncEntry?>()) { + return (data != null ? _i23.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.AuditLog?>()) { - return (data != null ? _i24.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.VisitSyncResult?>()) { + return (data != null ? _i24.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.ConsentLog?>()) { - return (data != null ? _i25.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.AuditLog?>()) { + return (data != null ? _i25.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.DataSubjectRequest?>()) { - return (data != null ? _i26.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i26.ConsentLog?>()) { + return (data != null ? _i26.ConsentLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i27.DataSubjectRequest?>()) { + return (data != null ? _i27.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.EnrollmentToken?>()) { - return (data != null ? _i27.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.EnrollmentToken?>()) { + return (data != null ? _i28.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.AlertStatus?>()) { - return (data != null ? _i28.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.AlertStatus?>()) { + return (data != null ? _i29.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.ArrivalMethod?>()) { - return (data != null ? _i29.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.ArrivalMethod?>()) { + return (data != null ? _i30.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.ConsentPurpose?>()) { - return (data != null ? _i30.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.ConsentPurpose?>()) { + return (data != null ? _i31.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i32.DataSubjectRequestStatus?>()) { return (data != null - ? _i31.DataSubjectRequestStatus.fromJson(data) + ? _i32.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.DataSubjectRequestType?>()) { - return (data != null ? _i32.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i33.DataSubjectRequestType?>()) { + return (data != null ? _i33.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.RiskLevel?>()) { - return (data != null ? _i33.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.RiskLevel?>()) { + return (data != null ? _i34.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.SyncStatus?>()) { - return (data != null ? _i34.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.SyncStatus?>()) { + return (data != null ? _i35.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.UserRole?>()) { - return (data != null ? _i35.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.UserRole?>()) { + return (data != null ? _i36.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i37.AlertDispatchUnavailableException?>()) { return (data != null - ? _i36.AlertDispatchUnavailableException.fromJson(data) + ? _i37.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.AlertPermissionException?>()) { + if (t == _i1.getType<_i38.AlertPermissionException?>()) { return (data != null - ? _i37.AlertPermissionException.fromJson(data) + ? _i38.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.AlertValidationException?>()) { + if (t == _i1.getType<_i39.AlertValidationException?>()) { return (data != null - ? _i38.AlertValidationException.fromJson(data) + ? _i39.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i40.AuthenticationFailedException?>()) { return (data != null - ? _i39.AuthenticationFailedException.fromJson(data) + ? _i40.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.DataRightsException?>()) { - return (data != null ? _i40.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i41.DataRightsException?>()) { + return (data != null ? _i41.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.EndpointDisabledException?>()) { + if (t == _i1.getType<_i42.EndpointDisabledException?>()) { return (data != null - ? _i41.EndpointDisabledException.fromJson(data) + ? _i42.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.EnrollmentException?>()) { - return (data != null ? _i42.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i43.EnrollmentException?>()) { + return (data != null ? _i43.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.NoticeDeliveryException?>()) { - return (data != null ? _i43.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i44.NoticeDeliveryException?>()) { + return (data != null ? _i44.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.OtpRequestException?>()) { - return (data != null ? _i44.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i45.OtpRequestException?>()) { + return (data != null ? _i45.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.MicroArea?>()) { - return (data != null ? _i45.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.MicroArea?>()) { + return (data != null ? _i46.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.OtpChallenge?>()) { - return (data != null ? _i46.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.OtpChallenge?>()) { + return (data != null ? _i47.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.Patient?>()) { - return (data != null ? _i47.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.Patient?>()) { + return (data != null ? _i48.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.PushToken?>()) { - return (data != null ? _i48.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.PushToken?>()) { + return (data != null ? _i49.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.TriageAnswer?>()) { - return (data != null ? _i49.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.TriageAnswer?>()) { + return (data != null ? _i50.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.TriageSession?>()) { - return (data != null ? _i50.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.TriageSession?>()) { + return (data != null ? _i51.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.Ubs?>()) { - return (data != null ? _i51.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.Ubs?>()) { + return (data != null ? _i52.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.User?>()) { - return (data != null ? _i52.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.User?>()) { + return (data != null ? _i53.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.UserCredential?>()) { - return (data != null ? _i53.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.UserCredential?>()) { + return (data != null ? _i54.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.Visit?>()) { - return (data != null ? _i54.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i55.Visit?>()) { + return (data != null ? _i55.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -537,24 +545,24 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i55.MicroAreaPatient>) { + if (t == List<_i56.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i55.MicroAreaPatient>(e)) + .map((e) => deserialize<_i56.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i56.VisitSyncResult>) { + if (t == List<_i57.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i56.VisitSyncResult>(e)) + .map((e) => deserialize<_i57.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i57.VisitSyncEntry>) { + if (t == List<_i58.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i57.VisitSyncEntry>(e)) + .map((e) => deserialize<_i58.VisitSyncEntry>(e)) .toList() as T; } @@ -583,40 +591,41 @@ class Protocol extends _i1.SerializationManager { _i19.ServiceHealth => 'ServiceHealth', _i20.TermsChangeNotice => 'TermsChangeNotice', _i21.TriageResult => 'TriageResult', - _i22.VisitSyncEntry => 'VisitSyncEntry', - _i23.VisitSyncResult => 'VisitSyncResult', - _i24.AuditLog => 'AuditLog', - _i25.ConsentLog => 'ConsentLog', - _i26.DataSubjectRequest => 'DataSubjectRequest', - _i27.EnrollmentToken => 'EnrollmentToken', - _i28.AlertStatus => 'AlertStatus', - _i29.ArrivalMethod => 'ArrivalMethod', - _i30.ConsentPurpose => 'ConsentPurpose', - _i31.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i32.DataSubjectRequestType => 'DataSubjectRequestType', - _i33.RiskLevel => 'RiskLevel', - _i34.SyncStatus => 'SyncStatus', - _i35.UserRole => 'UserRole', - _i36.AlertDispatchUnavailableException => + _i22.UbsContact => 'UbsContact', + _i23.VisitSyncEntry => 'VisitSyncEntry', + _i24.VisitSyncResult => 'VisitSyncResult', + _i25.AuditLog => 'AuditLog', + _i26.ConsentLog => 'ConsentLog', + _i27.DataSubjectRequest => 'DataSubjectRequest', + _i28.EnrollmentToken => 'EnrollmentToken', + _i29.AlertStatus => 'AlertStatus', + _i30.ArrivalMethod => 'ArrivalMethod', + _i31.ConsentPurpose => 'ConsentPurpose', + _i32.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i33.DataSubjectRequestType => 'DataSubjectRequestType', + _i34.RiskLevel => 'RiskLevel', + _i35.SyncStatus => 'SyncStatus', + _i36.UserRole => 'UserRole', + _i37.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i37.AlertPermissionException => 'AlertPermissionException', - _i38.AlertValidationException => 'AlertValidationException', - _i39.AuthenticationFailedException => 'AuthenticationFailedException', - _i40.DataRightsException => 'DataRightsException', - _i41.EndpointDisabledException => 'EndpointDisabledException', - _i42.EnrollmentException => 'EnrollmentException', - _i43.NoticeDeliveryException => 'NoticeDeliveryException', - _i44.OtpRequestException => 'OtpRequestException', - _i45.MicroArea => 'MicroArea', - _i46.OtpChallenge => 'OtpChallenge', - _i47.Patient => 'Patient', - _i48.PushToken => 'PushToken', - _i49.TriageAnswer => 'TriageAnswer', - _i50.TriageSession => 'TriageSession', - _i51.Ubs => 'Ubs', - _i52.User => 'User', - _i53.UserCredential => 'UserCredential', - _i54.Visit => 'Visit', + _i38.AlertPermissionException => 'AlertPermissionException', + _i39.AlertValidationException => 'AlertValidationException', + _i40.AuthenticationFailedException => 'AuthenticationFailedException', + _i41.DataRightsException => 'DataRightsException', + _i42.EndpointDisabledException => 'EndpointDisabledException', + _i43.EnrollmentException => 'EnrollmentException', + _i44.NoticeDeliveryException => 'NoticeDeliveryException', + _i45.OtpRequestException => 'OtpRequestException', + _i46.MicroArea => 'MicroArea', + _i47.OtpChallenge => 'OtpChallenge', + _i48.Patient => 'Patient', + _i49.PushToken => 'PushToken', + _i50.TriageAnswer => 'TriageAnswer', + _i51.TriageSession => 'TriageSession', + _i52.Ubs => 'Ubs', + _i53.User => 'User', + _i54.UserCredential => 'UserCredential', + _i55.Visit => 'Visit', _ => null, }; } @@ -671,71 +680,73 @@ class Protocol extends _i1.SerializationManager { return 'TermsChangeNotice'; case _i21.TriageResult(): return 'TriageResult'; - case _i22.VisitSyncEntry(): + case _i22.UbsContact(): + return 'UbsContact'; + case _i23.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i23.VisitSyncResult(): + case _i24.VisitSyncResult(): return 'VisitSyncResult'; - case _i24.AuditLog(): + case _i25.AuditLog(): return 'AuditLog'; - case _i25.ConsentLog(): + case _i26.ConsentLog(): return 'ConsentLog'; - case _i26.DataSubjectRequest(): + case _i27.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i27.EnrollmentToken(): + case _i28.EnrollmentToken(): return 'EnrollmentToken'; - case _i28.AlertStatus(): + case _i29.AlertStatus(): return 'AlertStatus'; - case _i29.ArrivalMethod(): + case _i30.ArrivalMethod(): return 'ArrivalMethod'; - case _i30.ConsentPurpose(): + case _i31.ConsentPurpose(): return 'ConsentPurpose'; - case _i31.DataSubjectRequestStatus(): + case _i32.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i32.DataSubjectRequestType(): + case _i33.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i33.RiskLevel(): + case _i34.RiskLevel(): return 'RiskLevel'; - case _i34.SyncStatus(): + case _i35.SyncStatus(): return 'SyncStatus'; - case _i35.UserRole(): + case _i36.UserRole(): return 'UserRole'; - case _i36.AlertDispatchUnavailableException(): + case _i37.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i37.AlertPermissionException(): + case _i38.AlertPermissionException(): return 'AlertPermissionException'; - case _i38.AlertValidationException(): + case _i39.AlertValidationException(): return 'AlertValidationException'; - case _i39.AuthenticationFailedException(): + case _i40.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i40.DataRightsException(): + case _i41.DataRightsException(): return 'DataRightsException'; - case _i41.EndpointDisabledException(): + case _i42.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i42.EnrollmentException(): + case _i43.EnrollmentException(): return 'EnrollmentException'; - case _i43.NoticeDeliveryException(): + case _i44.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i44.OtpRequestException(): + case _i45.OtpRequestException(): return 'OtpRequestException'; - case _i45.MicroArea(): + case _i46.MicroArea(): return 'MicroArea'; - case _i46.OtpChallenge(): + case _i47.OtpChallenge(): return 'OtpChallenge'; - case _i47.Patient(): + case _i48.Patient(): return 'Patient'; - case _i48.PushToken(): + case _i49.PushToken(): return 'PushToken'; - case _i49.TriageAnswer(): + case _i50.TriageAnswer(): return 'TriageAnswer'; - case _i50.TriageSession(): + case _i51.TriageSession(): return 'TriageSession'; - case _i51.Ubs(): + case _i52.Ubs(): return 'Ubs'; - case _i52.User(): + case _i53.User(): return 'User'; - case _i53.UserCredential(): + case _i54.UserCredential(): return 'UserCredential'; - case _i54.Visit(): + case _i55.Visit(): return 'Visit'; } return null; @@ -807,104 +818,107 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'TriageResult') { return deserialize<_i21.TriageResult>(data['data']); } + if (dataClassName == 'UbsContact') { + return deserialize<_i22.UbsContact>(data['data']); + } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i22.VisitSyncEntry>(data['data']); + return deserialize<_i23.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i23.VisitSyncResult>(data['data']); + return deserialize<_i24.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i24.AuditLog>(data['data']); + return deserialize<_i25.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i25.ConsentLog>(data['data']); + return deserialize<_i26.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i26.DataSubjectRequest>(data['data']); + return deserialize<_i27.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i27.EnrollmentToken>(data['data']); + return deserialize<_i28.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i28.AlertStatus>(data['data']); + return deserialize<_i29.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i29.ArrivalMethod>(data['data']); + return deserialize<_i30.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i30.ConsentPurpose>(data['data']); + return deserialize<_i31.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i31.DataSubjectRequestStatus>(data['data']); + return deserialize<_i32.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i32.DataSubjectRequestType>(data['data']); + return deserialize<_i33.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i33.RiskLevel>(data['data']); + return deserialize<_i34.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i34.SyncStatus>(data['data']); + return deserialize<_i35.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i35.UserRole>(data['data']); + return deserialize<_i36.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i36.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i37.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i37.AlertPermissionException>(data['data']); + return deserialize<_i38.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i38.AlertValidationException>(data['data']); + return deserialize<_i39.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i39.AuthenticationFailedException>(data['data']); + return deserialize<_i40.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i40.DataRightsException>(data['data']); + return deserialize<_i41.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i41.EndpointDisabledException>(data['data']); + return deserialize<_i42.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i42.EnrollmentException>(data['data']); + return deserialize<_i43.EnrollmentException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i43.NoticeDeliveryException>(data['data']); + return deserialize<_i44.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i44.OtpRequestException>(data['data']); + return deserialize<_i45.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i45.MicroArea>(data['data']); + return deserialize<_i46.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i46.OtpChallenge>(data['data']); + return deserialize<_i47.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i47.Patient>(data['data']); + return deserialize<_i48.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i48.PushToken>(data['data']); + return deserialize<_i49.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i49.TriageAnswer>(data['data']); + return deserialize<_i50.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i50.TriageSession>(data['data']); + return deserialize<_i51.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i51.Ubs>(data['data']); + return deserialize<_i52.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i52.User>(data['data']); + return deserialize<_i53.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i53.UserCredential>(data['data']); + return deserialize<_i54.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i54.Visit>(data['data']); + return deserialize<_i55.Visit>(data['data']); } return super.deserializeByClassName(data); } diff --git a/backend/sinalacs_client/lib/src/protocol/ubs.dart b/backend/sinalacs_client/lib/src/protocol/ubs.dart index 98fb63b1..b1dc570f 100644 --- a/backend/sinalacs_client/lib/src/protocol/ubs.dart +++ b/backend/sinalacs_client/lib/src/protocol/ubs.dart @@ -21,6 +21,7 @@ abstract class Ubs implements _i1.SerializableModel { required this.address, required this.city, required this.state, + this.contactPhone, }); factory Ubs({ @@ -29,6 +30,7 @@ abstract class Ubs implements _i1.SerializableModel { required String address, required String city, required String state, + String? contactPhone, }) = _UbsImpl; factory Ubs.fromJson(Map jsonSerialization) { @@ -40,6 +42,7 @@ abstract class Ubs implements _i1.SerializableModel { address: jsonSerialization['address'] as String, city: jsonSerialization['city'] as String, state: jsonSerialization['state'] as String, + contactPhone: jsonSerialization['contactPhone'] as String?, ); } @@ -56,6 +59,10 @@ abstract class Ubs implements _i1.SerializableModel { String state; + /// Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + /// unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + String? contactPhone; + /// Returns a shallow copy of this [Ubs] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -65,6 +72,7 @@ abstract class Ubs implements _i1.SerializableModel { String? address, String? city, String? state, + String? contactPhone, }); @override Map toJson() { @@ -75,6 +83,7 @@ abstract class Ubs implements _i1.SerializableModel { 'address': address, 'city': city, 'state': state, + if (contactPhone != null) 'contactPhone': contactPhone, }; } @@ -93,12 +102,14 @@ class _UbsImpl extends Ubs { required String address, required String city, required String state, + String? contactPhone, }) : super._( id: id, name: name, address: address, city: city, state: state, + contactPhone: contactPhone, ); /// Returns a shallow copy of this [Ubs] @@ -111,6 +122,7 @@ class _UbsImpl extends Ubs { String? address, String? city, String? state, + Object? contactPhone = _Undefined, }) { return Ubs( id: id is _i1.UuidValue? ? id : this.id, @@ -118,6 +130,7 @@ class _UbsImpl extends Ubs { address: address ?? this.address, city: city ?? this.city, state: state ?? this.state, + contactPhone: contactPhone is String? ? contactPhone : this.contactPhone, ); } } diff --git a/backend/sinalacs_server/bin/seed_e2e_fixtures.dart b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart index 53382e48..a1c2a4e7 100644 --- a/backend/sinalacs_server/bin/seed_e2e_fixtures.dart +++ b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart @@ -49,8 +49,8 @@ Future main(List args) async { try { await connection.runTx((tx) async { await tx.execute( - Sql.named('INSERT INTO "ubs" ("id","name","address","city","state") ' - "VALUES (@id, 'UBS E2E', 'Endereço de teste', 'São Paulo', 'SP')"), + Sql.named('INSERT INTO "ubs" ("id","name","address","city","state","contactPhone") ' + "VALUES (@id, 'UBS E2E', 'Endereço de teste', 'São Paulo', 'SP', '+55 11 5550-0199')"), parameters: {'id': fixtures.ubsId}, ); for (final entry in {fixtures.microAreaId: 'Microárea E2E', fixtures.otherMicroAreaId: 'Outra Microárea E2E'}.entries) { diff --git a/backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart b/backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart new file mode 100644 index 00000000..75b5d942 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart @@ -0,0 +1,43 @@ +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class UbsContactRecord { + const UbsContactRecord({required this.name, required this.phone}); + + final String name; + final String? phone; +} + +abstract interface class UbsContactStore { + /// UBS do ACS `acsId`, ou `null` se o ACS ou a UBS não existirem. + Future findForAcs(String acsId); +} + +/// Contato da UBS para o botão de escalonamento do ACS (RF13). +/// +/// A UBS vem do **token** (`user.id` é o id do ACS), nunca de parâmetro — mesma +/// regra de território das outras consultas do ACS. +class UbsContactService { + UbsContactService({required this.store}); + + final UbsContactStore store; + + Future contactFor(AuthenticatedUser user) async { + Authorization.require( + user, + roles: {UserRole.acs}, + onDenied: () => StateError('Somente ACS consultam o contato da UBS.'), + ); + + final record = await store.findForAcs(user.id); + if (record == null) { + throw StateError('UBS do ACS não encontrada.'); + } + final phone = record.phone?.trim(); + return UbsContact( + name: record.name, + phone: (phone == null || phone.isEmpty) ? null : phone, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart new file mode 100644 index 00000000..f98b9d5b --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart @@ -0,0 +1,19 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Contato da UBS do ACS (RF13). +class UbsEndpoint extends AuthenticatedEndpoint { + Future myContact( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + try { + return await AlertRuntime.instance.ubsContactServiceFor(session).contactFor(user); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart b/backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart new file mode 100644 index 00000000..327886fb --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart @@ -0,0 +1,96 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +/// precisa: o nome para a mensagem e o telefone para o discador. +abstract class UbsContact + implements _i1.SerializableModel, _i1.ProtocolSerialization { + UbsContact._({ + required this.name, + this.phone, + }); + + factory UbsContact({ + required String name, + String? phone, + }) = _UbsContactImpl; + + factory UbsContact.fromJson(Map jsonSerialization) { + return UbsContact( + name: jsonSerialization['name'] as String, + phone: jsonSerialization['phone'] as String?, + ); + } + + String name; + + String? phone; + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + UbsContact copyWith({ + String? name, + String? phone, + }); + @override + Map toJson() { + return { + '__className__': 'UbsContact', + 'name': name, + if (phone != null) 'phone': phone, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'UbsContact', + 'name': name, + if (phone != null) 'phone': phone, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _UbsContactImpl extends UbsContact { + _UbsContactImpl({ + required String name, + String? phone, + }) : super._( + name: name, + phone: phone, + ); + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + UbsContact copyWith({ + String? name, + Object? phone = _Undefined, + }) { + return UbsContact( + name: name ?? this.name, + phone: phone is String? ? phone : this.phone, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 9043ab8d..3680d58c 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -20,11 +20,12 @@ import '../endpoints/notices_endpoint.dart' as _i6; import '../endpoints/onboarding_endpoint.dart' as _i7; import '../endpoints/patients_endpoint.dart' as _i8; import '../endpoints/triage_endpoint.dart' as _i9; -import '../endpoints/visits_endpoint.dart' as _i10; +import '../endpoints/ubs_endpoint.dart' as _i10; +import '../endpoints/visits_endpoint.dart' as _i11; import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' - as _i11; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i12; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i13; class Endpoints extends _i1.EndpointDispatch { @override @@ -78,7 +79,13 @@ class Endpoints extends _i1.EndpointDispatch { 'triage', null, ), - 'visits': _i10.VisitsEndpoint() + 'ubs': _i10.UbsEndpoint() + ..initialize( + server, + 'ubs', + null, + ), + 'visits': _i11.VisitsEndpoint() ..initialize( server, 'visits', @@ -546,7 +553,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'purpose': _i1.ParameterDescription( name: 'purpose', - type: _i1.getType<_i11.ConsentPurpose>(), + type: _i1.getType<_i12.ConsentPurpose>(), nullable: false, ), 'granted': _i1.ParameterDescription( @@ -615,7 +622,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'purpose': _i1.ParameterDescription( name: 'purpose', - type: _i1.getType<_i11.ConsentPurpose>(), + type: _i1.getType<_i12.ConsentPurpose>(), nullable: false, ), }, @@ -755,6 +762,30 @@ class Endpoints extends _i1.EndpointDispatch { ), }, ); + connectors['ubs'] = _i1.EndpointConnector( + name: 'ubs', + endpoint: endpoints['ubs']!, + methodConnectors: { + 'myContact': _i1.MethodConnector( + name: 'myContact', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['ubs'] as _i10.UbsEndpoint).myContact( + session, + accessToken: params['accessToken'], + ), + ), + }, + ); connectors['visits'] = _i1.EndpointConnector( name: 'visits', endpoint: endpoints['visits']!, @@ -769,7 +800,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, @@ -777,7 +808,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i10.VisitsEndpoint).sync( + ) async => (endpoints['visits'] as _i11.VisitsEndpoint).sync( session, accessToken: params['accessToken'], visits: params['visits'], @@ -801,7 +832,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i10.VisitsEndpoint).pull( + ) async => (endpoints['visits'] as _i11.VisitsEndpoint).pull( session, accessToken: params['accessToken'], since: params['since'], diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index fdfdcf03..fac4b785 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -33,45 +33,46 @@ import 'api/red_alert_result.dart' as _i19; import 'api/service_health.dart' as _i20; import 'api/terms_change_notice.dart' as _i21; import 'api/triage_result.dart' as _i22; -import 'api/visit_sync_entry.dart' as _i23; -import 'api/visit_sync_result.dart' as _i24; -import 'audit_log.dart' as _i25; -import 'consent_log.dart' as _i26; -import 'data_subject_request.dart' as _i27; -import 'enrollment_token.dart' as _i28; -import 'enums/alert_status.dart' as _i29; -import 'enums/arrival_method.dart' as _i30; -import 'enums/consent_purpose.dart' as _i31; -import 'enums/data_subject_request_status.dart' as _i32; -import 'enums/data_subject_request_type.dart' as _i33; -import 'enums/risk_level.dart' as _i34; -import 'enums/sync_status.dart' as _i35; -import 'enums/user_role.dart' as _i36; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i37; -import 'exceptions/alert_permission_exception.dart' as _i38; -import 'exceptions/alert_validation_exception.dart' as _i39; -import 'exceptions/authentication_failed_exception.dart' as _i40; -import 'exceptions/data_rights_exception.dart' as _i41; -import 'exceptions/endpoint_disabled_exception.dart' as _i42; -import 'exceptions/enrollment_exception.dart' as _i43; -import 'exceptions/notice_delivery_exception.dart' as _i44; -import 'exceptions/otp_request_exception.dart' as _i45; -import 'micro_area.dart' as _i46; -import 'otp_challenge.dart' as _i47; -import 'patient.dart' as _i48; -import 'push_token.dart' as _i49; -import 'triage_answer.dart' as _i50; -import 'triage_session.dart' as _i51; -import 'ubs.dart' as _i52; -import 'user.dart' as _i53; -import 'user_credential.dart' as _i54; -import 'visit.dart' as _i55; +import 'api/ubs_contact.dart' as _i23; +import 'api/visit_sync_entry.dart' as _i24; +import 'api/visit_sync_result.dart' as _i25; +import 'audit_log.dart' as _i26; +import 'consent_log.dart' as _i27; +import 'data_subject_request.dart' as _i28; +import 'enrollment_token.dart' as _i29; +import 'enums/alert_status.dart' as _i30; +import 'enums/arrival_method.dart' as _i31; +import 'enums/consent_purpose.dart' as _i32; +import 'enums/data_subject_request_status.dart' as _i33; +import 'enums/data_subject_request_type.dart' as _i34; +import 'enums/risk_level.dart' as _i35; +import 'enums/sync_status.dart' as _i36; +import 'enums/user_role.dart' as _i37; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i38; +import 'exceptions/alert_permission_exception.dart' as _i39; +import 'exceptions/alert_validation_exception.dart' as _i40; +import 'exceptions/authentication_failed_exception.dart' as _i41; +import 'exceptions/data_rights_exception.dart' as _i42; +import 'exceptions/endpoint_disabled_exception.dart' as _i43; +import 'exceptions/enrollment_exception.dart' as _i44; +import 'exceptions/notice_delivery_exception.dart' as _i45; +import 'exceptions/otp_request_exception.dart' as _i46; +import 'micro_area.dart' as _i47; +import 'otp_challenge.dart' as _i48; +import 'patient.dart' as _i49; +import 'push_token.dart' as _i50; +import 'triage_answer.dart' as _i51; +import 'triage_session.dart' as _i52; +import 'ubs.dart' as _i53; +import 'user.dart' as _i54; +import 'user_credential.dart' as _i55; +import 'visit.dart' as _i56; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i56; -import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i57; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i58; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i59; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -92,6 +93,7 @@ export 'api/red_alert_result.dart'; export 'api/service_health.dart'; export 'api/terms_change_notice.dart'; export 'api/triage_result.dart'; +export 'api/ubs_contact.dart'; export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.dart'; export 'audit_log.dart'; @@ -1531,6 +1533,12 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: false, dartType: 'String', ), + _i2.ColumnDefinition( + name: 'contactPhone', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), ], foreignKeys: [], indexes: [ @@ -2015,104 +2023,107 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i22.TriageResult) { return _i22.TriageResult.fromJson(data) as T; } - if (t == _i23.VisitSyncEntry) { - return _i23.VisitSyncEntry.fromJson(data) as T; + if (t == _i23.UbsContact) { + return _i23.UbsContact.fromJson(data) as T; } - if (t == _i24.VisitSyncResult) { - return _i24.VisitSyncResult.fromJson(data) as T; + if (t == _i24.VisitSyncEntry) { + return _i24.VisitSyncEntry.fromJson(data) as T; } - if (t == _i25.AuditLog) { - return _i25.AuditLog.fromJson(data) as T; + if (t == _i25.VisitSyncResult) { + return _i25.VisitSyncResult.fromJson(data) as T; } - if (t == _i26.ConsentLog) { - return _i26.ConsentLog.fromJson(data) as T; + if (t == _i26.AuditLog) { + return _i26.AuditLog.fromJson(data) as T; } - if (t == _i27.DataSubjectRequest) { - return _i27.DataSubjectRequest.fromJson(data) as T; + if (t == _i27.ConsentLog) { + return _i27.ConsentLog.fromJson(data) as T; } - if (t == _i28.EnrollmentToken) { - return _i28.EnrollmentToken.fromJson(data) as T; + if (t == _i28.DataSubjectRequest) { + return _i28.DataSubjectRequest.fromJson(data) as T; } - if (t == _i29.AlertStatus) { - return _i29.AlertStatus.fromJson(data) as T; + if (t == _i29.EnrollmentToken) { + return _i29.EnrollmentToken.fromJson(data) as T; } - if (t == _i30.ArrivalMethod) { - return _i30.ArrivalMethod.fromJson(data) as T; + if (t == _i30.AlertStatus) { + return _i30.AlertStatus.fromJson(data) as T; } - if (t == _i31.ConsentPurpose) { - return _i31.ConsentPurpose.fromJson(data) as T; + if (t == _i31.ArrivalMethod) { + return _i31.ArrivalMethod.fromJson(data) as T; } - if (t == _i32.DataSubjectRequestStatus) { - return _i32.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i32.ConsentPurpose) { + return _i32.ConsentPurpose.fromJson(data) as T; } - if (t == _i33.DataSubjectRequestType) { - return _i33.DataSubjectRequestType.fromJson(data) as T; + if (t == _i33.DataSubjectRequestStatus) { + return _i33.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i34.RiskLevel) { - return _i34.RiskLevel.fromJson(data) as T; + if (t == _i34.DataSubjectRequestType) { + return _i34.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i35.SyncStatus) { - return _i35.SyncStatus.fromJson(data) as T; + if (t == _i35.RiskLevel) { + return _i35.RiskLevel.fromJson(data) as T; } - if (t == _i36.UserRole) { - return _i36.UserRole.fromJson(data) as T; + if (t == _i36.SyncStatus) { + return _i36.SyncStatus.fromJson(data) as T; } - if (t == _i37.AlertDispatchUnavailableException) { - return _i37.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i37.UserRole) { + return _i37.UserRole.fromJson(data) as T; } - if (t == _i38.AlertPermissionException) { - return _i38.AlertPermissionException.fromJson(data) as T; + if (t == _i38.AlertDispatchUnavailableException) { + return _i38.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i39.AlertValidationException) { - return _i39.AlertValidationException.fromJson(data) as T; + if (t == _i39.AlertPermissionException) { + return _i39.AlertPermissionException.fromJson(data) as T; } - if (t == _i40.AuthenticationFailedException) { - return _i40.AuthenticationFailedException.fromJson(data) as T; + if (t == _i40.AlertValidationException) { + return _i40.AlertValidationException.fromJson(data) as T; } - if (t == _i41.DataRightsException) { - return _i41.DataRightsException.fromJson(data) as T; + if (t == _i41.AuthenticationFailedException) { + return _i41.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i42.EndpointDisabledException) { - return _i42.EndpointDisabledException.fromJson(data) as T; + if (t == _i42.DataRightsException) { + return _i42.DataRightsException.fromJson(data) as T; } - if (t == _i43.EnrollmentException) { - return _i43.EnrollmentException.fromJson(data) as T; + if (t == _i43.EndpointDisabledException) { + return _i43.EndpointDisabledException.fromJson(data) as T; } - if (t == _i44.NoticeDeliveryException) { - return _i44.NoticeDeliveryException.fromJson(data) as T; + if (t == _i44.EnrollmentException) { + return _i44.EnrollmentException.fromJson(data) as T; } - if (t == _i45.OtpRequestException) { - return _i45.OtpRequestException.fromJson(data) as T; + if (t == _i45.NoticeDeliveryException) { + return _i45.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i46.MicroArea) { - return _i46.MicroArea.fromJson(data) as T; + if (t == _i46.OtpRequestException) { + return _i46.OtpRequestException.fromJson(data) as T; } - if (t == _i47.OtpChallenge) { - return _i47.OtpChallenge.fromJson(data) as T; + if (t == _i47.MicroArea) { + return _i47.MicroArea.fromJson(data) as T; } - if (t == _i48.Patient) { - return _i48.Patient.fromJson(data) as T; + if (t == _i48.OtpChallenge) { + return _i48.OtpChallenge.fromJson(data) as T; } - if (t == _i49.PushToken) { - return _i49.PushToken.fromJson(data) as T; + if (t == _i49.Patient) { + return _i49.Patient.fromJson(data) as T; } - if (t == _i50.TriageAnswer) { - return _i50.TriageAnswer.fromJson(data) as T; + if (t == _i50.PushToken) { + return _i50.PushToken.fromJson(data) as T; } - if (t == _i51.TriageSession) { - return _i51.TriageSession.fromJson(data) as T; + if (t == _i51.TriageAnswer) { + return _i51.TriageAnswer.fromJson(data) as T; } - if (t == _i52.Ubs) { - return _i52.Ubs.fromJson(data) as T; + if (t == _i52.TriageSession) { + return _i52.TriageSession.fromJson(data) as T; } - if (t == _i53.User) { - return _i53.User.fromJson(data) as T; + if (t == _i53.Ubs) { + return _i53.Ubs.fromJson(data) as T; } - if (t == _i54.UserCredential) { - return _i54.UserCredential.fromJson(data) as T; + if (t == _i54.User) { + return _i54.User.fromJson(data) as T; } - if (t == _i55.Visit) { - return _i55.Visit.fromJson(data) as T; + if (t == _i55.UserCredential) { + return _i55.UserCredential.fromJson(data) as T; + } + if (t == _i56.Visit) { + return _i56.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -2183,128 +2194,131 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i1.getType<_i22.TriageResult?>()) { return (data != null ? _i22.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.VisitSyncEntry?>()) { - return (data != null ? _i23.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.UbsContact?>()) { + return (data != null ? _i23.UbsContact.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i24.VisitSyncEntry?>()) { + return (data != null ? _i24.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.VisitSyncResult?>()) { - return (data != null ? _i24.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.VisitSyncResult?>()) { + return (data != null ? _i25.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.AuditLog?>()) { - return (data != null ? _i25.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.AuditLog?>()) { + return (data != null ? _i26.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.ConsentLog?>()) { - return (data != null ? _i26.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.ConsentLog?>()) { + return (data != null ? _i27.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.DataSubjectRequest?>()) { - return (data != null ? _i27.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i28.DataSubjectRequest?>()) { + return (data != null ? _i28.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.EnrollmentToken?>()) { - return (data != null ? _i28.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.EnrollmentToken?>()) { + return (data != null ? _i29.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.AlertStatus?>()) { - return (data != null ? _i29.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.AlertStatus?>()) { + return (data != null ? _i30.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.ArrivalMethod?>()) { - return (data != null ? _i30.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.ArrivalMethod?>()) { + return (data != null ? _i31.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.ConsentPurpose?>()) { - return (data != null ? _i31.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.ConsentPurpose?>()) { + return (data != null ? _i32.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i33.DataSubjectRequestStatus?>()) { return (data != null - ? _i32.DataSubjectRequestStatus.fromJson(data) + ? _i33.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.DataSubjectRequestType?>()) { - return (data != null ? _i33.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i34.DataSubjectRequestType?>()) { + return (data != null ? _i34.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.RiskLevel?>()) { - return (data != null ? _i34.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.RiskLevel?>()) { + return (data != null ? _i35.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.SyncStatus?>()) { - return (data != null ? _i35.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.SyncStatus?>()) { + return (data != null ? _i36.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.UserRole?>()) { - return (data != null ? _i36.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i37.UserRole?>()) { + return (data != null ? _i37.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i38.AlertDispatchUnavailableException?>()) { return (data != null - ? _i37.AlertDispatchUnavailableException.fromJson(data) + ? _i38.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.AlertPermissionException?>()) { + if (t == _i1.getType<_i39.AlertPermissionException?>()) { return (data != null - ? _i38.AlertPermissionException.fromJson(data) + ? _i39.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.AlertValidationException?>()) { + if (t == _i1.getType<_i40.AlertValidationException?>()) { return (data != null - ? _i39.AlertValidationException.fromJson(data) + ? _i40.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i41.AuthenticationFailedException?>()) { return (data != null - ? _i40.AuthenticationFailedException.fromJson(data) + ? _i41.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.DataRightsException?>()) { - return (data != null ? _i41.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i42.DataRightsException?>()) { + return (data != null ? _i42.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.EndpointDisabledException?>()) { + if (t == _i1.getType<_i43.EndpointDisabledException?>()) { return (data != null - ? _i42.EndpointDisabledException.fromJson(data) + ? _i43.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.EnrollmentException?>()) { - return (data != null ? _i43.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i44.EnrollmentException?>()) { + return (data != null ? _i44.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.NoticeDeliveryException?>()) { - return (data != null ? _i44.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i45.NoticeDeliveryException?>()) { + return (data != null ? _i45.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.OtpRequestException?>()) { - return (data != null ? _i45.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i46.OtpRequestException?>()) { + return (data != null ? _i46.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.MicroArea?>()) { - return (data != null ? _i46.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.MicroArea?>()) { + return (data != null ? _i47.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.OtpChallenge?>()) { - return (data != null ? _i47.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.OtpChallenge?>()) { + return (data != null ? _i48.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.Patient?>()) { - return (data != null ? _i48.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.Patient?>()) { + return (data != null ? _i49.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.PushToken?>()) { - return (data != null ? _i49.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.PushToken?>()) { + return (data != null ? _i50.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.TriageAnswer?>()) { - return (data != null ? _i50.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.TriageAnswer?>()) { + return (data != null ? _i51.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.TriageSession?>()) { - return (data != null ? _i51.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.TriageSession?>()) { + return (data != null ? _i52.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.Ubs?>()) { - return (data != null ? _i52.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.Ubs?>()) { + return (data != null ? _i53.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.User?>()) { - return (data != null ? _i53.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.User?>()) { + return (data != null ? _i54.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.UserCredential?>()) { - return (data != null ? _i54.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i55.UserCredential?>()) { + return (data != null ? _i55.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i55.Visit?>()) { - return (data != null ? _i55.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i56.Visit?>()) { + return (data != null ? _i56.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -2333,24 +2347,24 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i56.MicroAreaPatient>) { + if (t == List<_i57.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i56.MicroAreaPatient>(e)) + .map((e) => deserialize<_i57.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i57.VisitSyncResult>) { + if (t == List<_i58.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i57.VisitSyncResult>(e)) + .map((e) => deserialize<_i58.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i58.VisitSyncEntry>) { + if (t == List<_i59.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i58.VisitSyncEntry>(e)) + .map((e) => deserialize<_i59.VisitSyncEntry>(e)) .toList() as T; } @@ -2382,40 +2396,41 @@ class Protocol extends _i1.SerializationManagerServer { _i20.ServiceHealth => 'ServiceHealth', _i21.TermsChangeNotice => 'TermsChangeNotice', _i22.TriageResult => 'TriageResult', - _i23.VisitSyncEntry => 'VisitSyncEntry', - _i24.VisitSyncResult => 'VisitSyncResult', - _i25.AuditLog => 'AuditLog', - _i26.ConsentLog => 'ConsentLog', - _i27.DataSubjectRequest => 'DataSubjectRequest', - _i28.EnrollmentToken => 'EnrollmentToken', - _i29.AlertStatus => 'AlertStatus', - _i30.ArrivalMethod => 'ArrivalMethod', - _i31.ConsentPurpose => 'ConsentPurpose', - _i32.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i33.DataSubjectRequestType => 'DataSubjectRequestType', - _i34.RiskLevel => 'RiskLevel', - _i35.SyncStatus => 'SyncStatus', - _i36.UserRole => 'UserRole', - _i37.AlertDispatchUnavailableException => + _i23.UbsContact => 'UbsContact', + _i24.VisitSyncEntry => 'VisitSyncEntry', + _i25.VisitSyncResult => 'VisitSyncResult', + _i26.AuditLog => 'AuditLog', + _i27.ConsentLog => 'ConsentLog', + _i28.DataSubjectRequest => 'DataSubjectRequest', + _i29.EnrollmentToken => 'EnrollmentToken', + _i30.AlertStatus => 'AlertStatus', + _i31.ArrivalMethod => 'ArrivalMethod', + _i32.ConsentPurpose => 'ConsentPurpose', + _i33.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i34.DataSubjectRequestType => 'DataSubjectRequestType', + _i35.RiskLevel => 'RiskLevel', + _i36.SyncStatus => 'SyncStatus', + _i37.UserRole => 'UserRole', + _i38.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i38.AlertPermissionException => 'AlertPermissionException', - _i39.AlertValidationException => 'AlertValidationException', - _i40.AuthenticationFailedException => 'AuthenticationFailedException', - _i41.DataRightsException => 'DataRightsException', - _i42.EndpointDisabledException => 'EndpointDisabledException', - _i43.EnrollmentException => 'EnrollmentException', - _i44.NoticeDeliveryException => 'NoticeDeliveryException', - _i45.OtpRequestException => 'OtpRequestException', - _i46.MicroArea => 'MicroArea', - _i47.OtpChallenge => 'OtpChallenge', - _i48.Patient => 'Patient', - _i49.PushToken => 'PushToken', - _i50.TriageAnswer => 'TriageAnswer', - _i51.TriageSession => 'TriageSession', - _i52.Ubs => 'Ubs', - _i53.User => 'User', - _i54.UserCredential => 'UserCredential', - _i55.Visit => 'Visit', + _i39.AlertPermissionException => 'AlertPermissionException', + _i40.AlertValidationException => 'AlertValidationException', + _i41.AuthenticationFailedException => 'AuthenticationFailedException', + _i42.DataRightsException => 'DataRightsException', + _i43.EndpointDisabledException => 'EndpointDisabledException', + _i44.EnrollmentException => 'EnrollmentException', + _i45.NoticeDeliveryException => 'NoticeDeliveryException', + _i46.OtpRequestException => 'OtpRequestException', + _i47.MicroArea => 'MicroArea', + _i48.OtpChallenge => 'OtpChallenge', + _i49.Patient => 'Patient', + _i50.PushToken => 'PushToken', + _i51.TriageAnswer => 'TriageAnswer', + _i52.TriageSession => 'TriageSession', + _i53.Ubs => 'Ubs', + _i54.User => 'User', + _i55.UserCredential => 'UserCredential', + _i56.Visit => 'Visit', _ => null, }; } @@ -2470,71 +2485,73 @@ class Protocol extends _i1.SerializationManagerServer { return 'TermsChangeNotice'; case _i22.TriageResult(): return 'TriageResult'; - case _i23.VisitSyncEntry(): + case _i23.UbsContact(): + return 'UbsContact'; + case _i24.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i24.VisitSyncResult(): + case _i25.VisitSyncResult(): return 'VisitSyncResult'; - case _i25.AuditLog(): + case _i26.AuditLog(): return 'AuditLog'; - case _i26.ConsentLog(): + case _i27.ConsentLog(): return 'ConsentLog'; - case _i27.DataSubjectRequest(): + case _i28.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i28.EnrollmentToken(): + case _i29.EnrollmentToken(): return 'EnrollmentToken'; - case _i29.AlertStatus(): + case _i30.AlertStatus(): return 'AlertStatus'; - case _i30.ArrivalMethod(): + case _i31.ArrivalMethod(): return 'ArrivalMethod'; - case _i31.ConsentPurpose(): + case _i32.ConsentPurpose(): return 'ConsentPurpose'; - case _i32.DataSubjectRequestStatus(): + case _i33.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i33.DataSubjectRequestType(): + case _i34.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i34.RiskLevel(): + case _i35.RiskLevel(): return 'RiskLevel'; - case _i35.SyncStatus(): + case _i36.SyncStatus(): return 'SyncStatus'; - case _i36.UserRole(): + case _i37.UserRole(): return 'UserRole'; - case _i37.AlertDispatchUnavailableException(): + case _i38.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i38.AlertPermissionException(): + case _i39.AlertPermissionException(): return 'AlertPermissionException'; - case _i39.AlertValidationException(): + case _i40.AlertValidationException(): return 'AlertValidationException'; - case _i40.AuthenticationFailedException(): + case _i41.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i41.DataRightsException(): + case _i42.DataRightsException(): return 'DataRightsException'; - case _i42.EndpointDisabledException(): + case _i43.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i43.EnrollmentException(): + case _i44.EnrollmentException(): return 'EnrollmentException'; - case _i44.NoticeDeliveryException(): + case _i45.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i45.OtpRequestException(): + case _i46.OtpRequestException(): return 'OtpRequestException'; - case _i46.MicroArea(): + case _i47.MicroArea(): return 'MicroArea'; - case _i47.OtpChallenge(): + case _i48.OtpChallenge(): return 'OtpChallenge'; - case _i48.Patient(): + case _i49.Patient(): return 'Patient'; - case _i49.PushToken(): + case _i50.PushToken(): return 'PushToken'; - case _i50.TriageAnswer(): + case _i51.TriageAnswer(): return 'TriageAnswer'; - case _i51.TriageSession(): + case _i52.TriageSession(): return 'TriageSession'; - case _i52.Ubs(): + case _i53.Ubs(): return 'Ubs'; - case _i53.User(): + case _i54.User(): return 'User'; - case _i54.UserCredential(): + case _i55.UserCredential(): return 'UserCredential'; - case _i55.Visit(): + case _i56.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2610,104 +2627,107 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'TriageResult') { return deserialize<_i22.TriageResult>(data['data']); } + if (dataClassName == 'UbsContact') { + return deserialize<_i23.UbsContact>(data['data']); + } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i23.VisitSyncEntry>(data['data']); + return deserialize<_i24.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i24.VisitSyncResult>(data['data']); + return deserialize<_i25.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i25.AuditLog>(data['data']); + return deserialize<_i26.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i26.ConsentLog>(data['data']); + return deserialize<_i27.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i27.DataSubjectRequest>(data['data']); + return deserialize<_i28.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i28.EnrollmentToken>(data['data']); + return deserialize<_i29.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i29.AlertStatus>(data['data']); + return deserialize<_i30.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i30.ArrivalMethod>(data['data']); + return deserialize<_i31.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i31.ConsentPurpose>(data['data']); + return deserialize<_i32.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i32.DataSubjectRequestStatus>(data['data']); + return deserialize<_i33.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i33.DataSubjectRequestType>(data['data']); + return deserialize<_i34.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i34.RiskLevel>(data['data']); + return deserialize<_i35.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i35.SyncStatus>(data['data']); + return deserialize<_i36.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i36.UserRole>(data['data']); + return deserialize<_i37.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i37.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i38.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i38.AlertPermissionException>(data['data']); + return deserialize<_i39.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i39.AlertValidationException>(data['data']); + return deserialize<_i40.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i40.AuthenticationFailedException>(data['data']); + return deserialize<_i41.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i41.DataRightsException>(data['data']); + return deserialize<_i42.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i42.EndpointDisabledException>(data['data']); + return deserialize<_i43.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i43.EnrollmentException>(data['data']); + return deserialize<_i44.EnrollmentException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i44.NoticeDeliveryException>(data['data']); + return deserialize<_i45.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i45.OtpRequestException>(data['data']); + return deserialize<_i46.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i46.MicroArea>(data['data']); + return deserialize<_i47.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i47.OtpChallenge>(data['data']); + return deserialize<_i48.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i48.Patient>(data['data']); + return deserialize<_i49.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i49.PushToken>(data['data']); + return deserialize<_i50.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i50.TriageAnswer>(data['data']); + return deserialize<_i51.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i51.TriageSession>(data['data']); + return deserialize<_i52.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i52.Ubs>(data['data']); + return deserialize<_i53.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i53.User>(data['data']); + return deserialize<_i54.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i54.UserCredential>(data['data']); + return deserialize<_i55.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i55.Visit>(data['data']); + return deserialize<_i56.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2735,32 +2755,32 @@ class Protocol extends _i1.SerializationManagerServer { return _i6.AlertIdempotencyKey.t; case _i7.AlertOutboxEntry: return _i7.AlertOutboxEntry.t; - case _i25.AuditLog: - return _i25.AuditLog.t; - case _i26.ConsentLog: - return _i26.ConsentLog.t; - case _i27.DataSubjectRequest: - return _i27.DataSubjectRequest.t; - case _i28.EnrollmentToken: - return _i28.EnrollmentToken.t; - case _i46.MicroArea: - return _i46.MicroArea.t; - case _i47.OtpChallenge: - return _i47.OtpChallenge.t; - case _i48.Patient: - return _i48.Patient.t; - case _i49.PushToken: - return _i49.PushToken.t; - case _i51.TriageSession: - return _i51.TriageSession.t; - case _i52.Ubs: - return _i52.Ubs.t; - case _i53.User: - return _i53.User.t; - case _i54.UserCredential: - return _i54.UserCredential.t; - case _i55.Visit: - return _i55.Visit.t; + case _i26.AuditLog: + return _i26.AuditLog.t; + case _i27.ConsentLog: + return _i27.ConsentLog.t; + case _i28.DataSubjectRequest: + return _i28.DataSubjectRequest.t; + case _i29.EnrollmentToken: + return _i29.EnrollmentToken.t; + case _i47.MicroArea: + return _i47.MicroArea.t; + case _i48.OtpChallenge: + return _i48.OtpChallenge.t; + case _i49.Patient: + return _i49.Patient.t; + case _i50.PushToken: + return _i50.PushToken.t; + case _i52.TriageSession: + return _i52.TriageSession.t; + case _i53.Ubs: + return _i53.Ubs.t; + case _i54.User: + return _i54.User.t; + case _i55.UserCredential: + return _i55.UserCredential.t; + case _i56.Visit: + return _i56.Visit.t; } return null; } diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 6c74db11..185dbc8c 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -30,6 +30,8 @@ patients: - requestDataCorrection: triage: - evaluate: +ubs: + - myContact: visits: - sync: - pull: diff --git a/backend/sinalacs_server/lib/src/generated/ubs.dart b/backend/sinalacs_server/lib/src/generated/ubs.dart index b14a38f7..fc12b833 100644 --- a/backend/sinalacs_server/lib/src/generated/ubs.dart +++ b/backend/sinalacs_server/lib/src/generated/ubs.dart @@ -22,6 +22,7 @@ abstract class Ubs required this.address, required this.city, required this.state, + this.contactPhone, }); factory Ubs({ @@ -30,6 +31,7 @@ abstract class Ubs required String address, required String city, required String state, + String? contactPhone, }) = _UbsImpl; factory Ubs.fromJson(Map jsonSerialization) { @@ -41,6 +43,7 @@ abstract class Ubs address: jsonSerialization['address'] as String, city: jsonSerialization['city'] as String, state: jsonSerialization['state'] as String, + contactPhone: jsonSerialization['contactPhone'] as String?, ); } @@ -59,6 +62,10 @@ abstract class Ubs String state; + /// Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + /// unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + String? contactPhone; + @override _i1.Table<_i1.UuidValue?> get table => t; @@ -71,6 +78,7 @@ abstract class Ubs String? address, String? city, String? state, + String? contactPhone, }); @override Map toJson() { @@ -81,6 +89,7 @@ abstract class Ubs 'address': address, 'city': city, 'state': state, + if (contactPhone != null) 'contactPhone': contactPhone, }; } @@ -93,6 +102,7 @@ abstract class Ubs 'address': address, 'city': city, 'state': state, + if (contactPhone != null) 'contactPhone': contactPhone, }; } @@ -135,12 +145,14 @@ class _UbsImpl extends Ubs { required String address, required String city, required String state, + String? contactPhone, }) : super._( id: id, name: name, address: address, city: city, state: state, + contactPhone: contactPhone, ); /// Returns a shallow copy of this [Ubs] @@ -153,6 +165,7 @@ class _UbsImpl extends Ubs { String? address, String? city, String? state, + Object? contactPhone = _Undefined, }) { return Ubs( id: id is _i1.UuidValue? ? id : this.id, @@ -160,6 +173,7 @@ class _UbsImpl extends Ubs { address: address ?? this.address, city: city ?? this.city, state: state ?? this.state, + contactPhone: contactPhone is String? ? contactPhone : this.contactPhone, ); } } @@ -186,6 +200,12 @@ class UbsUpdateTable extends _i1.UpdateTable { table.state, value, ); + + _i1.ColumnValue contactPhone(String? value) => + _i1.ColumnValue( + table.contactPhone, + value, + ); } class UbsTable extends _i1.Table<_i1.UuidValue?> { @@ -207,6 +227,10 @@ class UbsTable extends _i1.Table<_i1.UuidValue?> { 'state', this, ); + contactPhone = _i1.ColumnString( + 'contactPhone', + this, + ); } late final UbsUpdateTable updateTable; @@ -219,6 +243,10 @@ class UbsTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnString state; + /// Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + /// unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + late final _i1.ColumnString contactPhone; + @override List<_i1.Column> get columns => [ id, @@ -226,6 +254,7 @@ class UbsTable extends _i1.Table<_i1.UuidValue?> { address, city, state, + contactPhone, ]; } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart new file mode 100644 index 00000000..bdce030e --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart @@ -0,0 +1,18 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class OrmUbsContactStore implements UbsContactStore { + OrmUbsContactStore(this._session); + + final Session _session; + + @override + Future findForAcs(String acsId) async { + final acs = await Acs.db.findById(_session, UuidValue.fromString(acsId)); + if (acs == null) return null; + final ubs = await Ubs.db.findById(_session, acs.ubsId); + if (ubs == null) return null; + return UbsContactRecord(name: ubs.name, phone: ubs.contactPhone); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql b/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql index 426035ca..ce7ed1fb 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql +++ b/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql @@ -12,6 +12,10 @@ INSERT INTO "ubs" ("id", "name", "address", "city", "state") VALUES ('00000000-0000-4000-8000-000000000004', 'UBS Desenvolvimento', 'Endereço local', 'São Paulo', 'SP') ON CONFLICT ("id") DO NOTHING; +-- Telefone sintético (RF13). O UPDATE cobre bancos de dev criados antes da coluna. +UPDATE "ubs" SET "contactPhone" = '+55 11 5550-0100' +WHERE "id" = '00000000-0000-4000-8000-000000000004' AND "contactPhone" IS NULL; + INSERT INTO "micro_areas" ("id", "name", "ubsId", "geoJsonBoundary") VALUES ('00000000-0000-4000-8000-000000000003', 'Microárea 12', '00000000-0000-4000-8000-000000000004', '{}') ON CONFLICT ("id") DO NOTHING; diff --git a/backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml b/backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml new file mode 100644 index 00000000..7e96aca7 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml @@ -0,0 +1,6 @@ +### Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +### precisa: o nome para a mensagem e o telefone para o discador. +class: UbsContact +fields: + name: String + phone: String? diff --git a/backend/sinalacs_server/lib/src/models/ubs.spy.yaml b/backend/sinalacs_server/lib/src/models/ubs.spy.yaml index 44f82504..54a68359 100644 --- a/backend/sinalacs_server/lib/src/models/ubs.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/ubs.spy.yaml @@ -7,3 +7,6 @@ fields: address: String city: String state: String + ### Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + ### unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + contactPhone: String? diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index c872d369..88eec0ae 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -14,6 +14,7 @@ import 'package:sinalacs_server/src/application/patients/data_subject_rights_ser import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart'; import 'package:sinalacs_server/src/application/patients/patient_directory_service.dart'; import 'package:sinalacs_server/src/application/triage/triage_session_service.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; import 'package:sinalacs_server/src/application/visits/visit_sync_service.dart'; import 'package:sinalacs_server/src/config/app_config.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; @@ -34,6 +35,7 @@ import 'package:sinalacs_server/src/application/patients/push_token_service.dart import 'package:sinalacs_server/src/infrastructure/database/orm_patient_data_overview_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_patient_directory_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_triage_session_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_ubs_contact_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_visit_store.dart'; import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; @@ -202,6 +204,10 @@ class AlertRuntime { audit: auditTrailFor(session), ); + /// Contato da UBS do ACS (RF13), para uma requisição. + UbsContactService ubsContactServiceFor(Session session) => + UbsContactService(store: OrmUbsContactStore(session)); + /// Constrói o diretório de pacientes da microárea para uma requisição. PatientDirectoryService patientDirectoryServiceFor(Session session) => PatientDirectoryService( diff --git a/backend/sinalacs_server/migrations/20261002153125316/definition.json b/backend/sinalacs_server/migrations/20261002153125316/definition.json new file mode 100644 index 00000000..acdb3840 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/definition.json @@ -0,0 +1,3423 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationCell", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "otp_challenges", + "dartName": "OtpChallenge", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContact", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRisk", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultDisplay", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "user_credentials", + "dartName": "UserCredential", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBefore", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfter", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:RiskLevel?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage", + "dartName": "CloudStorageEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "addedTime", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "byteData", + "columnType": 5, + "isNullable": false, + "dartType": "dart:typed_data:ByteData" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "verified", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_path_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_expiration", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "expiration" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage_direct_upload", + "dartName": "CloudStorageDirectUploadEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_direct_upload_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authKey", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_storage_path", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_future_call", + "dartName": "FutureCallEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_future_call_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serializedObject", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "identifier", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_serverId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_identifier_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "identifier" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_connection_info", + "dartName": "ServerHealthConnectionInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_connection_info_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "closing", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idle", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_metric", + "dartName": "ServerHealthMetric", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_metric_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isHealthy", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "value", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "name" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_log", + "dartName": "LogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reference", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logLevel", + "columnType": 6, + "isNullable": false, + "dartType": "protocol:LogLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_message_log", + "dartName": "MessageLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_message_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageName", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_message_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_message_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_method", + "dartName": "MethodInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_method_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_endpoint_method_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "endpoint" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "method" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_migrations", + "dartName": "DatabaseMigrationVersion", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_migrations_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_ids", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "module" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_query_log", + "dartName": "QueryLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_query_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "query", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numRows", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_query_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_readwrite_test", + "dartName": "ReadWriteTestEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_readwrite_test_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "number", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_readwrite_test_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_runtime_settings", + "dartName": "RuntimeSettings", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_runtime_settings_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettings", + "columnType": 8, + "isNullable": false, + "dartType": "protocol:LogSettings" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettingsOverrides", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logServiceCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logMalformedCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_runtime_settings_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_session_log", + "dartName": "SessionLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_session_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": true, + "dartType": "double?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numQueries", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authenticatedUserId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isOpen", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "touched", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_serverid_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_touched_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "touched" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_isopen_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "isOpen" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "sinalacs", + "version": "20261002153125316" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002153125316/definition.sql b/backend/sinalacs_server/migrations/20261002153125316/definition.sql new file mode 100644 index 00000000..8b3dfef9 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/definition.sql @@ -0,0 +1,715 @@ +BEGIN; + +-- +-- Class Acs as table acs +-- +CREATE TABLE "acs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "ubsId" uuid NOT NULL, + "active" boolean NOT NULL, + "lastSyncAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "acs_ubs_idx" ON "acs" USING btree ("ubsId"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AlertDeliveryRecord as table alert_deliveries +-- +CREATE TABLE "alert_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "acknowledgedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_deliveries_alert_acs_key" ON "alert_deliveries" USING btree ("alertId", "acsId"); + +-- +-- Class AlertIdempotencyKey as table alert_idempotency_keys +-- +CREATE TABLE "alert_idempotency_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "key" text NOT NULL, + "alertId" uuid NOT NULL, + "locationHash" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_idempotency_keys_key_key" ON "alert_idempotency_keys" USING btree ("key"); + +-- +-- Class AlertOutboxEntry as table alert_outbox +-- +CREATE TABLE "alert_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "topic" text NOT NULL, + "payload" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "publishedAt" timestamp without time zone, + "attempts" bigint NOT NULL, + "nextAttemptAt" timestamp without time zone NOT NULL, + "lastError" text +); + +-- Indexes +CREATE INDEX "alert_outbox_pending_idx" ON "alert_outbox" USING btree ("publishedAt", "nextAttemptAt"); + +-- +-- Class Alert as table alerts +-- +CREATE TABLE "alerts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid, + "microAreaId" uuid, + "triggeredAt" timestamp without time zone NOT NULL, + "receivedAt" timestamp without time zone, + "respondedAt" timestamp without time zone, + "acknowledgedAt" timestamp without time zone, + "riskLevel" text NOT NULL, + "locationHash" text NOT NULL, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditLog as table audit_logs +-- +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "actionType" text NOT NULL, + "resourceType" text NOT NULL, + "resourceId" uuid, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "result" text NOT NULL, + "sequence" bigint NOT NULL, + "previousHash" text NOT NULL, + "entryHash" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_logs_sequence_idx" ON "audit_logs" USING btree ("sequence"); + +-- +-- Class ConsentLog as table consent_logs +-- +CREATE TABLE "consent_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "purpose" text NOT NULL, + "action" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "userAgent" text NOT NULL, + "signature" text NOT NULL +); + +-- +-- Class DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- Class MicroArea as table micro_areas +-- +CREATE TABLE "micro_areas" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "ubsId" uuid NOT NULL, + "geoJsonBoundary" text NOT NULL +); + +-- Indexes +CREATE INDEX "micro_areas_ubs_idx" ON "micro_areas" USING btree ("ubsId"); + +-- +-- Class OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "resultRisk" text NOT NULL, + "resultDisplay" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "deviceId" text NOT NULL +); + +-- +-- Class Ubs as table ubs +-- +CREATE TABLE "ubs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "address" text NOT NULL, + "city" text NOT NULL, + "state" text NOT NULL, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- Class User as table users +-- +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "cpfHash" text NOT NULL, + "name" text NOT NULL, + "birthDate" timestamp without time zone NOT NULL, + "role" text NOT NULL, + "microAreaId" uuid, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "users_cpf_hash_key" ON "users" USING btree ("cpfHash"); +CREATE INDEX "users_micro_area_idx" ON "users" USING btree ("microAreaId"); + +-- +-- Class Visit as table visits +-- +CREATE TABLE "visits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "scheduledAt" timestamp without time zone NOT NULL, + "startedAt" timestamp without time zone, + "completedAt" timestamp without time zone, + "status" text NOT NULL, + "riskLevelBefore" text NOT NULL, + "riskLevelAfter" text, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "localId" uuid NOT NULL, + "syncAt" timestamp without time zone, + "version" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "visits_local_id_key" ON "visits" USING btree ("localId"); + +-- +-- Class CloudStorageEntry as table serverpod_cloud_storage +-- +CREATE TABLE "serverpod_cloud_storage" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "addedTime" timestamp without time zone NOT NULL, + "expiration" timestamp without time zone, + "byteData" bytea NOT NULL, + "verified" boolean NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_path_idx" ON "serverpod_cloud_storage" USING btree ("storageId", "path"); +CREATE INDEX "serverpod_cloud_storage_expiration" ON "serverpod_cloud_storage" USING btree ("expiration"); + +-- +-- Class CloudStorageDirectUploadEntry as table serverpod_cloud_storage_direct_upload +-- +CREATE TABLE "serverpod_cloud_storage_direct_upload" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "expiration" timestamp without time zone NOT NULL, + "authKey" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_direct_upload_storage_path" ON "serverpod_cloud_storage_direct_upload" USING btree ("storageId", "path"); + +-- +-- Class FutureCallEntry as table serverpod_future_call +-- +CREATE TABLE "serverpod_future_call" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "serializedObject" text, + "serverId" text NOT NULL, + "identifier" text +); + +-- Indexes +CREATE INDEX "serverpod_future_call_time_idx" ON "serverpod_future_call" USING btree ("time"); +CREATE INDEX "serverpod_future_call_serverId_idx" ON "serverpod_future_call" USING btree ("serverId"); +CREATE INDEX "serverpod_future_call_identifier_idx" ON "serverpod_future_call" USING btree ("identifier"); + +-- +-- Class ServerHealthConnectionInfo as table serverpod_health_connection_info +-- +CREATE TABLE "serverpod_health_connection_info" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "active" bigint NOT NULL, + "closing" bigint NOT NULL, + "idle" bigint NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_connection_info_timestamp_idx" ON "serverpod_health_connection_info" USING btree ("timestamp", "serverId", "granularity"); + +-- +-- Class ServerHealthMetric as table serverpod_health_metric +-- +CREATE TABLE "serverpod_health_metric" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "isHealthy" boolean NOT NULL, + "value" double precision NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_metric_timestamp_idx" ON "serverpod_health_metric" USING btree ("timestamp", "serverId", "name", "granularity"); + +-- +-- Class LogEntry as table serverpod_log +-- +CREATE TABLE "serverpod_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "reference" text, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "logLevel" bigint NOT NULL, + "message" text NOT NULL, + "error" text, + "stackTrace" text, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_log_sessionLogId_idx" ON "serverpod_log" USING btree ("sessionLogId"); + +-- +-- Class MessageLogEntry as table serverpod_message_log +-- +CREATE TABLE "serverpod_message_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "serverId" text NOT NULL, + "messageId" bigint NOT NULL, + "endpoint" text NOT NULL, + "messageName" text NOT NULL, + "duration" double precision NOT NULL, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- +-- Class MethodInfo as table serverpod_method +-- +CREATE TABLE "serverpod_method" ( + "id" bigserial PRIMARY KEY, + "endpoint" text NOT NULL, + "method" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_method_endpoint_method_idx" ON "serverpod_method" USING btree ("endpoint", "method"); + +-- +-- Class DatabaseMigrationVersion as table serverpod_migrations +-- +CREATE TABLE "serverpod_migrations" ( + "id" bigserial PRIMARY KEY, + "module" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_migrations_ids" ON "serverpod_migrations" USING btree ("module"); + +-- +-- Class QueryLogEntry as table serverpod_query_log +-- +CREATE TABLE "serverpod_query_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "query" text NOT NULL, + "duration" double precision NOT NULL, + "numRows" bigint, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_query_log_sessionLogId_idx" ON "serverpod_query_log" USING btree ("sessionLogId"); + +-- +-- Class ReadWriteTestEntry as table serverpod_readwrite_test +-- +CREATE TABLE "serverpod_readwrite_test" ( + "id" bigserial PRIMARY KEY, + "number" bigint NOT NULL +); + +-- +-- Class RuntimeSettings as table serverpod_runtime_settings +-- +CREATE TABLE "serverpod_runtime_settings" ( + "id" bigserial PRIMARY KEY, + "logSettings" json NOT NULL, + "logSettingsOverrides" json NOT NULL, + "logServiceCalls" boolean NOT NULL, + "logMalformedCalls" boolean NOT NULL +); + +-- +-- Class SessionLogEntry as table serverpod_session_log +-- +CREATE TABLE "serverpod_session_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "module" text, + "endpoint" text, + "method" text, + "duration" double precision, + "numQueries" bigint, + "slow" boolean, + "error" text, + "stackTrace" text, + "authenticatedUserId" bigint, + "userId" text, + "isOpen" boolean, + "touched" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_session_log_serverid_idx" ON "serverpod_session_log" USING btree ("serverId"); +CREATE INDEX "serverpod_session_log_time_idx" ON "serverpod_session_log" USING btree ("time"); +CREATE INDEX "serverpod_session_log_touched_idx" ON "serverpod_session_log" USING btree ("touched"); +CREATE INDEX "serverpod_session_log_isopen_idx" ON "serverpod_session_log" USING btree ("isOpen"); + +-- +-- Foreign relations for "alert_deliveries" table +-- +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_idempotency_keys" table +-- +ALTER TABLE ONLY "alert_idempotency_keys" + ADD CONSTRAINT "alert_idempotency_keys_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_outbox" table +-- +ALTER TABLE ONLY "alert_outbox" + ADD CONSTRAINT "alert_outbox_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alerts" table +-- +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "audit_logs" table +-- +ALTER TABLE ONLY "audit_logs" + ADD CONSTRAINT "audit_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "consent_logs" table +-- +ALTER TABLE ONLY "consent_logs" + ADD CONSTRAINT "consent_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "triage_sessions" table +-- +ALTER TABLE ONLY "triage_sessions" + ADD CONSTRAINT "triage_sessions_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "visits" table +-- +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_log" table +-- +ALTER TABLE ONLY "serverpod_log" + ADD CONSTRAINT "serverpod_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_message_log" table +-- +ALTER TABLE ONLY "serverpod_message_log" + ADD CONSTRAINT "serverpod_message_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_query_log" table +-- +ALTER TABLE ONLY "serverpod_query_log" + ADD CONSTRAINT "serverpod_query_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261002153125316', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002153125316', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/20261002153125316/definition_project.json b/backend/sinalacs_server/migrations/20261002153125316/definition_project.json new file mode 100644 index 00000000..96378926 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/definition_project.json @@ -0,0 +1,2087 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationCell", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "otp_challenges", + "dartName": "OtpChallenge", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContact", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRisk", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultDisplay", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "user_credentials", + "dartName": "UserCredential", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBefore", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfter", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:RiskLevel?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002153125316/migration.json b/backend/sinalacs_server/migrations/20261002153125316/migration.json new file mode 100644 index 00000000..156b3b4e --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/migration.json @@ -0,0 +1,32 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "ubs", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002153125316/migration.sql b/backend/sinalacs_server/migrations/20261002153125316/migration.sql new file mode 100644 index 00000000..87de6e6a --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/migration.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "ubs" ADD COLUMN "contactPhone" text; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261002153125316', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002153125316', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/migration_registry.txt b/backend/sinalacs_server/migrations/migration_registry.txt index 64b206fe..87918467 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -15,3 +15,4 @@ 20260919032710552 20260929005339393 20260929192823862 +20261002153125316 diff --git a/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart index 1d36169b..a778ca60 100644 --- a/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart +++ b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart @@ -40,10 +40,11 @@ import 'package:sinalacs_server/src/generated/api/terms_change_notice.dart' import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' as _i17; import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i18; +import 'package:sinalacs_server/src/generated/api/ubs_contact.dart' as _i19; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i19; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i20; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i21; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/generated/endpoints.dart'; export 'package:serverpod_test/serverpod_test_public_exports.dart'; @@ -174,6 +175,8 @@ class TestEndpoints { late final _TriageEndpoint triage; + late final _UbsEndpoint ubs; + late final _VisitsEndpoint visits; } @@ -216,6 +219,10 @@ class _InternalTestEndpoints extends TestEndpoints endpoints, serializationManager, ); + ubs = _UbsEndpoint( + endpoints, + serializationManager, + ); visits = _VisitsEndpoint( endpoints, serializationManager, @@ -1142,6 +1149,48 @@ class _TriageEndpoint { } } +class _UbsEndpoint { + _UbsEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i19.UbsContact> myContact( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'ubs', + method: 'myContact', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'ubs', + methodName: 'myContact', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i19.UbsContact>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + class _VisitsEndpoint { _VisitsEndpoint( this._endpointDispatch, @@ -1152,10 +1201,10 @@ class _VisitsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i20.VisitSyncEntry> visits, + required List<_i21.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -1179,7 +1228,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1187,7 +1236,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -1214,7 +1263,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); diff --git a/backend/sinalacs_server/test/unit/ubs_contact_service_test.dart b/backend/sinalacs_server/test/unit/ubs_contact_service_test.dart new file mode 100644 index 00000000..5dd18c9c --- /dev/null +++ b/backend/sinalacs_server/test/unit/ubs_contact_service_test.dart @@ -0,0 +1,76 @@ +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; + +class _FakeStore implements UbsContactStore { + _FakeStore(this.record); + UbsContactRecord? record; + final consultados = []; + + @override + Future findForAcs(String acsId) async { + consultados.add(acsId); + return record; + } +} + +AuthenticatedUser _usuario(UserRole role) => AuthenticatedUser( + id: _acsId, + role: role, + microAreaId: '00000000-0000-4000-8000-000000000003', + deviceId: 'dispositivo-teste', +); + +void main() { + test('devolve nome e telefone da UBS do ACS', () async { + final service = UbsContactService( + store: _FakeStore( + const UbsContactRecord(name: 'UBS Teste', phone: '+55 11 5550-0100'), + ), + ); + + final contato = await service.contactFor(_usuario(UserRole.acs)); + + expect(contato.name, 'UBS Teste'); + expect(contato.phone, '+55 11 5550-0100'); + }); + + test('UBS sem telefone cadastrado devolve phone nulo, sem falhar', () async { + final service = UbsContactService( + store: _FakeStore( + const UbsContactRecord(name: 'UBS Sem Fone', phone: null), + ), + ); + final contato = await service.contactFor(_usuario(UserRole.acs)); + expect(contato.phone, isNull); + }); + + test('telefone só com espaços vale como não cadastrado', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS', phone: ' ')), + ); + expect((await service.contactFor(_usuario(UserRole.acs))).phone, isNull); + }); + + test('paciente não consulta o contato (nem toca no banco)', () async { + final store = _FakeStore(const UbsContactRecord(name: 'UBS', phone: '1')); + final service = UbsContactService(store: store); + + expect( + () => service.contactFor(_usuario(UserRole.patient)), + throwsA(isA()), + ); + expect(store.consultados, isEmpty); + }); + + test('ACS sem linha na tabela acs é erro, não contato vazio', () async { + final service = UbsContactService(store: _FakeStore(null)); + expect( + () => service.contactFor(_usuario(UserRole.acs)), + throwsA(isA()), + ); + }); +} diff --git a/docs/roteiro-video-sponsor.md b/docs/roteiro-video-sponsor.md index 23baed24..86ef6c98 100644 --- a/docs/roteiro-video-sponsor.md +++ b/docs/roteiro-video-sponsor.md @@ -151,7 +151,7 @@ Todos disparam um `SnackBar` de "não integrado" e queimam a demonstração na f |------|----------|----------------------| | `MapScreen` | "Traçar rota eficiente" | "Traçado de rota depende da integração de mapas." — e a própria tela imprime **"Mapa demonstrativo"** | | `EscalationScreen` | "Ligar para o SAMU (192)" | abre o discador do aparelho com 192 (sem discador: "Ligue manualmente para 192") | -| `EscalationScreen` | "Encaminhar para UBS Central" | "Encaminhamento será integrado à UBS." | +| `EscalationScreen` | "Ligar para a UBS" | abre o discador com o telefone da UBS (sem telefone cadastrado: "ainda não cadastrou um telefone") | | `GeofencingScreen` | "Abrir formulário da visita" | "Use a tela Visita para registrar o atendimento." | | `NoticesScreen` | "Preparar aviso" | "Envio depende da integração de notificações push." | | `TerritorializationScreen` | "Atualizar dados da microárea" | "Atualização será integrada à API central." — a tela pode aparecer, o botão não pode ser tocado | diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index e511b694..3d3bfa6d 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -132,6 +132,7 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `address` | `text` | Dado Institucional Público | String de endereço | Endereço físico do equipamento de saúde pública. | | | `city` | `text` | Dado Territorial Público | String de município | Município de lotação da UBS. | | | `state` | `text` | Dado Territorial Público | String UF | Estado da federação de lotação da UBS. | +| | `contactPhone` | `text` (nullable) | Dado da unidade (não pessoal) | Telefone para escalonamento (RF13) | Nullable: sem telefone, o app avisa e não liga. Entregue ao ACS por `ubs.myContact`. | | **serverpod_query_log** | `query` | `text` | **Risco Crítico de Fuga Indireta** | Texto SQL completo de queries lentas/falhas | **Atualizado:** ainda crítico para `name`/`emergencyContact` (texto claro). Para `chronicConditions`/`answers`/`notes`, a cifragem acontece na aplicação **antes** de a query ser montada (§2.3), então o valor que chegaria a esta tabela — se o log for reativado — já é ciphertext, não texto claro; risco rebaixado para esses três campos especificamente (ver §3.2.2). | | | Demais colunas | Vários | Metadados de Sistema | Timestamps, durações e IDs numéricos | Métricas de telemetria e depuração de queries do banco de dados. | | **serverpod_message_log** | `error` / `stackTrace` | `text` | Risco Moderado de Vazamento | Dump de exceções não tratadas | Risco de exposição de payloads RPC contendo dados clínicos sensíveis ou identificadores em stacktraces não sanitizados. | diff --git a/spec/ux_accessibility_assessment.md b/spec/ux_accessibility_assessment.md index 9749c0ed..54017a10 100644 --- a/spec/ux_accessibility_assessment.md +++ b/spec/ux_accessibility_assessment.md @@ -122,7 +122,7 @@ passava pelo mapeamento, confirmado e corrigido junto com a auditoria de contras | Paciente | Botões de Login e Envio de Triagem | `48 x 52 dp` | 48 x 48 dp | Conforme | | ACS | Ações de Login, Confirmar Recebimento e Sincronizar | `48 x 52 dp` | 48 x 48 dp | Conforme | | ACS | "Ligar para o SAMU (192)" | ~~`48x52 dp`~~ **sem `minimumSize` → 40dp de altura visual** | 60 x 60 dp | **Corrigido para `64x60 dp`** — a medição anterior ("48x52 dp") estava incorreta; o botão não declarava `minimumSize` e caía no default do Material 3. | -| ACS | "Salvar e enfileirar sincronização", "Descartar recusada(s)", "Encaminhar para UBS Central", "Iniciar rota de visita" (escalonamento) | sem `minimumSize` (40dp) | 48 x 48 dp | **Corrigido para `48x52 dp`**, não detectados no relatório anterior | +| ACS | "Salvar e enfileirar sincronização", "Descartar recusada(s)", "Ligar para a UBS", "Iniciar rota de visita" (escalonamento) | sem `minimumSize` (40dp) | 48 x 48 dp | **Corrigido para `48x52 dp`**, não detectados no relatório anterior | Reprodução: `meetsGuideline(androidTapTargetGuideline)` em ambos os apps (`login_flow_test.dart`, `patient_app_mvp_test.dart`). diff --git a/spec/validation_report.md b/spec/validation_report.md index 43883786..2353f156 100644 --- a/spec/validation_report.md +++ b/spec/validation_report.md @@ -86,7 +86,7 @@ cliente · **`parcial`** = existe, mas alimentado por dado fabricado · | RF10 | Mapa interativo | **parcial** | Coordenadas são **fabricadas** a partir do hash — ver L-05. | | RF11 | Registro rápido de visitas offline-first | **backend** | `visits.sync` com dedupe por `localId`, versionamento, conflito e território. Fila SQLCipher no dispositivo. | | RF12 | Geofencing (check-in passivo) | **ausente** | `RouteService` calcula chegada localmente, mas não há GPS em segundo plano. | -| RF13 | Escalonamento para SAMU/UBS | **parcial** | O botão do SAMU abre o discador com 192 (`EmergencyDialer`, sem ligar sozinho; 2026-10-01). O da UBS segue sendo um aviso: não há fonte de contato da UBS. L-07 fechado só para o SAMU. | +| RF13 | Escalonamento para SAMU/UBS | **parcial** | O botão do SAMU abre o discador com 192 (`EmergencyDialer`, sem ligar sozinho; 2026-10-01). O da UBS também liga (`ubs.myContact` entrega `ubs.contactPhone`; sem telefone cadastrado, avisa e não liga; 2026-10-02). O telefone vem da seed: quem o cadastra é o backoffice, ainda mock. | | RF14 | Avisos segmentados (push) | **ausente** | `NoticesScreen` descarta a entrada. Sem envio; contrato revisado para Gorush (ver `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2). | | RF15 | Sincronização bidirecional | **backend** | Dispositivo → central e central → dispositivo funcionam e são testados dos dois lados: ACS (`visits.pull`) e paciente (`alerts.statusFor`, RF05). Ambos rodam automaticamente ao abrir a tela, em ciclo periódico enquanto o app está em primeiro plano (ACS, em qualquer aba) ou enquanto a tela de Status está aberta (paciente), e por botão manual. Do lado ACS, o pull continua sendo só referência somente leitura (contagem exibida na tela), sem gravar as visitas puxadas na fila offline local — persistir esse resultado como registro local segue como trabalho futuro (ver nota em `VisitPullService`), fora do escopo deste plano. | | RF16 | Motor de triagem determinístico | **backend** | `TriageEngine`, determinismo verificado em teste de integração. INV-02 preservado. | From 7160e4c807e59ccf9ee6509426402c94bd071ac7 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 11:56:10 -0400 Subject: [PATCH 33/43] =?UTF-8?q?fix(acs):=20consulta=20da=20UBS=20n=C3=A3?= =?UTF-8?q?o=20trava=20o=20bot=C3=A3o=20do=20SAMU;=20telefone=20sem=20d?= =?UTF-8?q?=C3=ADgitos=20vale=20como=20n=C3=A3o=20cadastrado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acs/lib/app/app.dart | 19 +++++++++------- apps/acs/test/escalation_ubs_test.dart | 31 ++++++++++++++++++++++++++ apps/acs/test/support/fakes.dart | 4 ++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 66d253ed..243612ed 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -1937,6 +1937,9 @@ class _EscalationScreenState extends State { /// Trava o toque duplo: dois `launchUrl` seguidos empilham dois discadores. bool _dialing = false; + /// Trava própria da consulta da UBS: nunca pode desabilitar o botão do SAMU. + bool _fetchingUbs = false; + Future _callSamu() async { if (_dialing) return; setState(() => _dialing = true); @@ -1955,17 +1958,17 @@ class _EscalationScreenState extends State { /// Liga para a UBS do ACS (RF13). O telefone vem do servidor; sem ele, avisa. Future _callUbs() async { - if (_dialing) return; - setState(() => _dialing = true); + if (_fetchingUbs) return; + setState(() => _fetchingUbs = true); String? aviso; try { final contato = await BackendScope.of(context).ubsContact(); - final telefone = contato.phone?.trim(); - if (telefone == null || telefone.isEmpty) { + final telefone = contato.phone?.trim() ?? ''; + // O discador recebe só dígitos e `+`: espaço e hífen viram %20 no `tel:`. + final numero = telefone.replaceAll(RegExp(r'[^0-9+]'), ''); + if (numero.isEmpty) { aviso = '${contato.name} ainda não cadastrou um telefone. Acione a coordenação.'; } else { - // O discador recebe só dígitos e `+`: espaço e hífen viram %20 no `tel:`. - final numero = telefone.replaceAll(RegExp(r'[^0-9+]'), ''); if (!await widget.dialer.dial(numero)) { aviso = 'Não foi possível abrir o discador. Ligue manualmente para $telefone.'; } @@ -1976,7 +1979,7 @@ class _EscalationScreenState extends State { aviso = 'Não foi possível obter o contato da UBS.'; } if (!mounted) return; - setState(() => _dialing = false); + setState(() => _fetchingUbs = false); if (aviso != null) _message(context, aviso); } @@ -2004,7 +2007,7 @@ class _EscalationScreenState extends State { const SizedBox(height: 12), OutlinedButton.icon( key: const Key('escalation_ubs'), - onPressed: _dialing ? null : _callUbs, + onPressed: _fetchingUbs ? null : _callUbs, style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), icon: const Icon(Icons.local_hospital_outlined), label: const Text('Ligar para a UBS'), diff --git a/apps/acs/test/escalation_ubs_test.dart b/apps/acs/test/escalation_ubs_test.dart index 1bbc6c7c..3585624f 100644 --- a/apps/acs/test/escalation_ubs_test.dart +++ b/apps/acs/test/escalation_ubs_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/app/app.dart'; @@ -77,4 +79,33 @@ void main() { expect(dialer.discados, isEmpty); expect(find.text('Sem conexão.'), findsOneWidget); }); + + testWidgets('SAMU continua ativo enquanto a consulta da UBS está pendente', (tester) async { + final backend = FakeAcsBackend()..ubsContactGate = Completer(); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pump(); + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.discados, ['192']); + + backend.ubsContactGate!.complete(); + await tester.pumpAndSettle(); + expect(dialer.discados, ['192', '+551155500100']); + }); + + testWidgets('telefone sem dígitos (n/d) vale como não cadastrado e não disca', (tester) async { + final backend = FakeAcsBackend()..ubsContactResult = UbsContact(name: 'UBS X', phone: 'n/d'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.textContaining('UBS X ainda não cadastrou um telefone'), findsOneWidget); + }); } diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index de358c0e..f4c24009 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -133,9 +133,13 @@ class FakeAcsBackend implements AcsBackend { int ubsContactCount = 0; + /// Se definido, `ubsContact()` espera por ele (simula rede lenta). + Completer? ubsContactGate; + @override Future ubsContact() async { ubsContactCount++; + await ubsContactGate?.future; final falha = ubsContactFailure; if (falha != null) throw falha; return ubsContactResult; From 43c58fa37417768c819bbafe4052305b679b454a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:10:14 -0400 Subject: [PATCH 34/43] =?UTF-8?q?feat(acs):=20RF08=20=E2=80=94=20micro?= =?UTF-8?q?=C3=A1rea=20persistida=20na=20base=20SQLCipher,=20por=20dono=20?= =?UTF-8?q?e=20com=20validade=20de=2072=20h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 3 +- apps/CLAUDE.md | 2 + .../integration_test/full_journey_e2e.dart | 38 +++++ apps/acs/lib/app/app.dart | 79 ++++++++++- .../lib/core/database/encrypted_database.dart | 35 ++++- .../core/database/micro_area_cache_store.dart | 106 ++++++++++++++ .../core/services/micro_area_directory.dart | 91 ++++++++++++ .../micro_area_directory_factory.dart | 19 +++ apps/acs/lib/main.dart | 9 +- apps/acs/test/encrypted_database_test.dart | 39 ++++++ .../acs/test/micro_area_cache_store_test.dart | 70 ++++++++++ apps/acs/test/micro_area_cache_ui_test.dart | 81 +++++++++++ apps/acs/test/micro_area_directory_test.dart | 130 ++++++++++++++++++ .../test/support/memory_micro_area_store.dart | 42 ++++++ spec/lgpd_data_audit.md | 5 + spec/lgpd_design.md | 14 ++ spec/validation_report.md | 6 +- 17 files changed, 754 insertions(+), 15 deletions(-) create mode 100644 apps/acs/lib/core/database/micro_area_cache_store.dart create mode 100644 apps/acs/lib/core/services/micro_area_directory.dart create mode 100644 apps/acs/lib/core/services/micro_area_directory_factory.dart create mode 100644 apps/acs/test/micro_area_cache_store_test.dart create mode 100644 apps/acs/test/micro_area_cache_ui_test.dart create mode 100644 apps/acs/test/micro_area_directory_test.dart create mode 100644 apps/acs/test/support/memory_micro_area_store.dart diff --git a/PROGRESS.md b/PROGRESS.md index 02c52155..3587afb3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -568,7 +568,8 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). - **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). - **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, MFA/refresh token, iOS, botão da UBS. -- **Fora:** RF08 (cache persistido da microárea offline — exige desenho de LGPD antes), MFA/refresh token, iOS (`apps/acs/ios/` não existe). +- **Fora:** MFA/refresh token, iOS (`apps/acs/ios/` não existe). +- **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Migração para Serverpod diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 91ad10a9..af383113 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -48,6 +48,8 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. O botão "Ligar para a UBS" (RF13) usa o mesmo discador com o telefone que `AcsBackend.ubsContact()` busca em `ubs.myContact`; sem telefone cadastrado, avisa e não liga. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto). +**ACS: cache da microárea (RF08).** `MicroAreaDirectory` (`core/services/micro_area_directory.dart`) é a única regra de quando o cache serve: só no lugar de uma falha **recuperável** (`BackendFailure.isRecoverable`), só para o mesmo `userId|microAreaId` e em até 72 h (`microAreaCacheMaxAge`); recusa do servidor sobe como veio, e dono diferente ou lista vencida **apaga** o cache. `MicroAreaCacheStore` guarda nome, `isChronic` e condições na mesma base SQLCipher das visitas (schema v6, migração aditiva); gravar o cache nunca derruba a lista fresca. `SinalAcsApp(microAreaDirectory:)` é opcional e `null` significa sem cache (o Keystore não existe no `flutter test`): quem liga o de produção é o `main.dart` (`buildMicroAreaDirectory`), guardado por `test/micro_area_cache_ui_test.dart`. As telas Área e Visita mostram `micro_area_cache_notice(_area)` quando a lista é do cache. Política em `spec/lgpd_design.md` §5.11. + ### Admin app (`apps/admin/`) Package `sinalacs_admin`, read-only backoffice. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. Unlike ACS/patient it does not consume `sinalacs_client` yet: `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's `.spy.yaml` models field-for-field, backed today only by `MockAdminDataSource` — swapping in a real implementation over `sinalacs_client` shouldn't require reshaping the screens, the same DI pattern as `PatientBackend`/`AcsBackend`. `AdminHomeShell` exposes four read-only sections: **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding), **Alertas** (filterable by micro-area/status, with filter options sourced from the fetched data itself rather than a hardcoded list — a hardcoded list would silently drop a real micro-area as a selectable option) and **Auditoria** (an `audit_logs` viewer). Every screen touching sensitive data calls `dataSource.recordAccess(actionType: 'view', resourceType: ...)` *before* rendering it, per `spec/PRD_system.md` §4.2.2 (admin access must itself be audited) — the audit log screen audits its own access too, and a failed `recordAccess` blocks the data from rendering rather than failing open. Login is intentionally local-only, **not** wired to `auth.developmentLogin`: the backend's `auth_endpoint.dart` only accepts `role: 'patient'`/`role: 'acs'`, there is no fixed dev user for `admin`, so real auth is out of scope until the backend supports it (see the `LoginScreen` doc comment in `app.dart`). The "ambiente de desenvolvimento" banner is informational, not access control — the actual gate is `devLoginEnabled`, which defaults to `kDebugMode` and disables the login bypass outside debug builds. Layout is desktop-first per `spec/PRD_system.md` §2.1: `NavigationRail` above `AdminBreakpoints.rail`, `NavigationBar` below, same `ThemeData` either way. The breakpoints live in `lib/app/admin_layout.dart` (`rail` 640, `stacked` 480, `counterCardMin` 160) plus `adminHeaderHeight()`, which exists because `PreferredSizeWidget.preferredSize` is a getter with no `BuildContext` — without it the two-line header clips instead of growing under a large system font. Android was added after the web-only phase, and mobile is a complement, never a replacement: below `stacked` the two alert filters stack instead of sharing a row, `_LinhaComSelo` moves a `ListTile`'s `trailing` badge down into the subtitle (Microáreas and Auditoria had the same squeeze), `_InfoRow` stacks label over value, the header's "Acesso auditado" chip becomes an icon that keeps its `Semantics` label, and the counter cards use a computed grid instead of a fixed `width: 160`. `NavigationRail` is `scrollable: true` because a phone in landscape clears the 640 threshold with only ~288dp of height left — it fits by a few pixels at default font size and overflows at 150%. The guard against regressing any of this is that the ten original widget tests still pass **unedited** at 800×600; the new `test/responsive_layout_test.dart`, `text_scale_test.dart`, `alerts_filters_layout_test.dart`, `admin_header_test.dart` and `touch_targets_test.dart` drive `test/support/layout_harness.dart`, whose header documents the three ways an overflow test silently goes blind (checking before paint, never scrolling a `ListView` past the first fold, and `takeException()` consuming one exception per call) — `test/layout_harness_sanity_test.dart` proves the detector still detects. **`apps/admin/integration_test/` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource`, so `flutter test integration_test -d emulator-5554` needs no `docker compose`, no seed and no `--dart-define`. `apps/admin` is the only one of the three apps with Flutter Web enabled and the only one versioning `pubspec.lock` from the start (the other two adopted that convention later). `docs/telas-admin.md` documents its five screens with real screenshots, mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index 298413ea..542c4c09 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -24,8 +24,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:sinalacs_acs/app/app.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' as api; @@ -154,4 +158,38 @@ void main() { expect(noServidor.containsAll(enviados), isTrue, reason: 'as 100 visitas estão no servidor, conferidas por pull'); }); + + testWidgets('RF08: a microárea fica no aparelho, sobrevive à queda de rede e está cifrada', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + // O `keyStore` de PRODUÇÃO (Keystore do aparelho), numa base própria deste teste. + await EncryptedLocalDatabase.deleteDatabaseFile('rf08_e2e.db'); + final store = MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore(), databaseName: 'rf08_e2e.db'); + final online = MicroAreaDirectory(fetch: backend.listPatients, session: () => backend.session, store: store); + final primeira = await online.load(); + expect(primeira.fromCache, isFalse); + expect(primeira.patients.any((p) => p.patientId == main.id), isTrue); + + // Rede caída: a central "não responde", o aparelho serve o que guardou. + final offline = MicroAreaDirectory( + fetch: () async => throw const BackendFailure('Sem conexão.'), + session: () => backend.session, + store: store, + ); + final segunda = await offline.load(); + expect(segunda.fromCache, isTrue); + expect(segunda.patients.any((p) => p.patientId == main.id), isTrue); + + // O arquivo no disco NÃO contém o nome do paciente em texto claro (SQLCipher no aparelho). + final bytes = await File(await EncryptedLocalDatabase.pathFor('rf08_e2e.db')).readAsBytes(); + expect(String.fromCharCodes(bytes).contains(main.name), isFalse, + reason: 'o nome do paciente não pode estar legível no arquivo do banco'); + expect(String.fromCharCodes(bytes.take(16)), isNot(startsWith('SQLite format 3'))); + await store.clear(); + }); } diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 243612ed..7196133c 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -14,6 +14,7 @@ import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/alert_feed.dart'; import 'package:sinalacs_acs/core/services/alert_queue.dart'; import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; import 'package:sinalacs_acs/core/services/reconnect_schedule.dart'; @@ -30,6 +31,7 @@ class SinalAcsApp extends StatefulWidget { this.feedBuilder, this.visitQueue, this.visitPullService, + this.microAreaDirectory, this.initialAlert, this.currentPosition, this.themeController, @@ -49,6 +51,12 @@ class SinalAcsApp extends StatefulWidget { final AlertFeed Function(AlertQueue queue)? feedBuilder; final OfflineVisitQueue? visitQueue; final VisitPullService? visitPullService; + + /// Lista da microárea com cache no aparelho (RF08). `null` lê direto do + /// backend, sem cache: quem monta o de produção é o `main.dart` + /// (`buildMicroAreaDirectory`). O padrão não monta um sozinho porque o + /// Keystore não existe no `flutter test` e a carga ficaria pendurada. + final MicroAreaDirectory? microAreaDirectory; final PrioritizedAlert? initialAlert; final LatLng? currentPosition; final ThemeController? themeController; @@ -124,6 +132,7 @@ class _SinalAcsAppState extends State { feedBuilder: widget.feedBuilder, visitQueue: _visitQueue, visitPullService: _visitPullService, + directory: widget.microAreaDirectory, initialAlert: widget.initialAlert, initialPosition: widget.currentPosition, themeController: _themeController, @@ -140,6 +149,7 @@ class LoginScreen extends StatefulWidget { required this.themeController, required this.visitPullService, super.key, + this.directory, this.feedBuilder, this.initialAlert, this.initialPosition, @@ -149,6 +159,7 @@ class LoginScreen extends StatefulWidget { final AlertFeed Function(AlertQueue queue)? feedBuilder; final OfflineVisitQueue visitQueue; final VisitPullService visitPullService; + final MicroAreaDirectory? directory; final PrioritizedAlert? initialAlert; final LatLng? initialPosition; final ThemeController themeController; @@ -210,6 +221,7 @@ class _LoginScreenState extends State { feedBuilder: widget.feedBuilder, visitQueue: widget.visitQueue, visitPullService: widget.visitPullService, + directory: widget.directory, initialAlert: widget.initialAlert, initialPosition: widget.initialPosition, themeController: widget.themeController, @@ -293,6 +305,7 @@ class AcsHomeShell extends StatefulWidget { required this.themeController, required this.visitPullService, super.key, + this.directory, this.feedBuilder, this.initialAlert, this.initialPosition, @@ -326,6 +339,9 @@ class AcsHomeShell extends StatefulWidget { /// já foi o defeito de outra fila neste mesmo arquivo. final VisitPullService visitPullService; + /// Lista da microárea com cache no aparelho (RF08); `null` lê direto do backend. + final MicroAreaDirectory? directory; + @override State createState() => _AcsHomeShellState(); } @@ -389,6 +405,9 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver /// Quando a última carga bem-sucedida terminou. DateTime? _microAreaPatientsLoadedAt; + /// `true` quando a lista veio do cache do aparelho (RF08), não da central. + bool _microAreaFromCache = false; + /// Presente quando a última tentativa falhou. InfraNotice? _microAreaPatientsError; @@ -435,14 +454,18 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver Future _loadMicroAreaPatients() async { final backend = BackendScope.of(context); - setState(() { _loadingMicroAreaPatients = true; _microAreaPatientsError = null; }); + setState(() { _loadingMicroAreaPatients = true; _microAreaPatientsError = null; _microAreaFromCache = false; }); try { - final patients = await backend.listPatients(); + final snapshot = widget.directory == null + ? null + : await widget.directory!.load(); + final patients = snapshot?.patients ?? await backend.listPatients(); if (!mounted) return; setState(() { _microAreaPatients = patients; - _microAreaPatientsLoadedAt = DateTime.now(); + _microAreaPatientsLoadedAt = snapshot?.fetchedAt ?? DateTime.now(); + _microAreaFromCache = snapshot?.fromCache ?? false; }); } on BackendFailure catch (failure) { if (!mounted) return; @@ -748,6 +771,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver loadingPatients: _loadingMicroAreaPatients, patientCount: _microAreaPatients?.length, patientsLoadedAt: _microAreaPatientsLoadedAt, + patientsFromCache: _microAreaFromCache, patientsError: _microAreaPatientsError, ), AcsDestination.queue => DashboardScreen( @@ -769,6 +793,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver }), ), AcsDestination.visit => VisitRegistrationScreen( + directory: widget.directory, alert: _selected, queue: widget.visitQueue, currentPosition: _currentPosition, @@ -823,6 +848,7 @@ class TerritorializationScreen extends StatelessWidget { this.loadingPatients = false, this.patientCount, this.patientsLoadedAt, + this.patientsFromCache = false, this.patientsError, }); @@ -852,6 +878,9 @@ class TerritorializationScreen extends StatelessWidget { /// Quando a última carga bem-sucedida terminou. final DateTime? patientsLoadedAt; + /// `true` quando a contagem veio do cache do aparelho (RF08). + final bool patientsFromCache; + /// Presente quando a última tentativa falhou. final InfraNotice? patientsError; @@ -861,6 +890,8 @@ class TerritorializationScreen extends StatelessWidget { const SizedBox(height: 12), _InfoRow('Pacientes sincronizados', _patientCountText()), _InfoRow('Cache local', _cacheFreshnessText()), + if (patientsFromCache && patientsLoadedAt != null) + _cacheNotice(context, const Key('micro_area_cache_notice_area'), patientsLoadedAt!), // A linha acima diz que falhou; este banner diz por quê. Sem ele o // `detail` de `patientsError` (a mensagem do servidor) nunca chegava à // tela — o `InfraNotice` inteiro era reduzido a "Não foi possível @@ -1521,8 +1552,28 @@ class _LegendChip extends StatelessWidget { ); } +String _formatarDataHora(DateTime utc) { + final d = utc.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(d.day)}/${dois(d.month)} às ${dois(d.hour)}:${dois(d.minute)}'; +} + +/// Aviso de que a lista da microárea é a guardada no aparelho (RF08, §5.11 de +/// `spec/lgpd_design.md`): o ACS precisa saber que não é a lista da central. +Widget _cacheNotice(BuildContext context, Key key, DateTime fetchedAt) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + key: key, + 'Sem conexão com a central: lista salva em ${_formatarDataHora(fetchedAt)}.', + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + ), + ), + ); + class VisitRegistrationScreen extends StatefulWidget { - const VisitRegistrationScreen({required this.queue, super.key, this.alert, this.currentPosition}); + const VisitRegistrationScreen({required this.queue, super.key, this.alert, this.currentPosition, this.directory}); /// A fila é recebida pronta, não construída aqui: uma instância por gravação /// descartava a visita assim que o callback retornava. @@ -1530,6 +1581,9 @@ class VisitRegistrationScreen extends StatefulWidget { final PrioritizedAlert? alert; final LatLng? currentPosition; + /// Lista da microárea com cache no aparelho (RF08); `null` lê direto do backend. + final MicroAreaDirectory? directory; + @override State createState() => _VisitRegistrationScreenState(); } @@ -1554,6 +1608,8 @@ class _VisitRegistrationScreenState extends State { List? _patients; BackendFailure? _patientsError; bool _loadingPatients = false; + bool _patientsFromCache = false; + DateTime? _patientsFetchedAt; bool _patientsRequested = false; @override @@ -1600,11 +1656,18 @@ class _VisitRegistrationScreenState extends State { /// funcionando mesmo sem rede (o app é offline-first), então o erro vira /// aviso com um jeito de tentar de novo, nunca uma tela presa. Future _loadPatients() async { - setState(() { _loadingPatients = true; _patientsError = null; }); + final backend = BackendScope.of(context); + setState(() { _loadingPatients = true; _patientsError = null; _patientsFromCache = false; }); try { - final result = await BackendScope.of(context).listPatients(); + final snapshot = widget.directory == null ? null : await widget.directory!.load(); + final result = snapshot?.patients ?? await backend.listPatients(); if (!mounted) return; - setState(() { _patients = result; _loadingPatients = false; }); + setState(() { + _patients = result; + _patientsFromCache = snapshot?.fromCache ?? false; + _patientsFetchedAt = snapshot?.fetchedAt; + _loadingPatients = false; + }); } on BackendFailure catch (failure) { if (!mounted) return; setState(() { _patientsError = failure; _loadingPatients = false; }); @@ -1762,6 +1825,8 @@ class _VisitRegistrationScreenState extends State { : all.where((p) => p.name.toLowerCase().contains(query)).toList(); return [ + if (_patientsFromCache && _patientsFetchedAt != null) + _cacheNotice(context, const Key('micro_area_cache_notice'), _patientsFetchedAt!), const SizedBox(height: 8), TextField( key: const Key('patient_search'), diff --git a/apps/acs/lib/core/database/encrypted_database.dart b/apps/acs/lib/core/database/encrypted_database.dart index 302277c9..3f10243c 100644 --- a/apps/acs/lib/core/database/encrypted_database.dart +++ b/apps/acs/lib/core/database/encrypted_database.dart @@ -22,8 +22,10 @@ class EncryptedLocalDatabase { /// ficar visível no aparelho em vez de retentar para sempre. v5 acrescenta /// `sync_cursor`, para o cursor de `visits.pull` (`SyncCursorStore`) — dado /// operacional do dispositivo, não uma visita, mas vive no mesmo banco - /// criptografado por estar sob a mesma política de proteção. - static const schemaVersion = 5; + /// criptografado por estar sob a mesma política de proteção. v6 acrescenta + /// `micro_area_cache` e `micro_area_cache_meta`, o cache da microárea (RF08, + /// `spec/lgpd_design.md` §5.11). + static const schemaVersion = 6; /// Visitas registradas offline, aguardando sincronização. /// @@ -53,7 +55,24 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( value TEXT NOT NULL )'''; - /// Migração v1 → v2, v2 → v3, v3 → v4 e v4 → v5. + /// Cache da microárea (RF08, §5.11 de spec/lgpd_design.md): a última lista que + /// o servidor devolveu, de um dono (`userId|microAreaId`), com a data do + /// download. Sob a mesma criptografia das visitas. + static const createMicroAreaCache = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache ( + patient_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_chronic INTEGER NOT NULL, + chronic_conditions TEXT NOT NULL +)'''; + + static const createMicroAreaCacheMeta = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +)'''; + + /// Migração v1 → v2, v2 → v3, v3 → v4, v4 → v5 e v5 → v6. /// /// Nenhuma das duas formas de v1 guarda o UUID do paciente: `patient_name` /// era `'Paciente ' + 8 dos 32 dígitos hex`, irreversível. Sem UUID, @@ -104,6 +123,12 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( // então nenhuma visita pendente ou recusada existente é perdida. await db.execute(createSyncCursor); } + + if (from < 6) { + // Aditiva: `CREATE TABLE IF NOT EXISTS` não toca em nada que já existe. + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); + } } /// Caminho do arquivo do banco neste dispositivo. @@ -156,6 +181,8 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( onCreate: (db, version) async { await db.execute(createOfflineVisits); await db.execute(createSyncCursor); + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); }, onUpgrade: _upgrade, // Um rollback de APK abriria um arquivo v2 pedindo v1 e lançaria, @@ -171,6 +198,8 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( onCreate: (db, version) async { await db.execute(createOfflineVisits); await db.execute(createSyncCursor); + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); }, onUpgrade: _upgrade, onDowngrade: onDatabaseDowngradeDelete, diff --git a/apps/acs/lib/core/database/micro_area_cache_store.dart b/apps/acs/lib/core/database/micro_area_cache_store.dart new file mode 100644 index 00000000..a7ec9258 --- /dev/null +++ b/apps/acs/lib/core/database/micro_area_cache_store.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' show ConflictAlgorithm, Database; + +class CachedMicroArea { + const CachedMicroArea({required this.patients, required this.fetchedAt}); + + final List patients; + final DateTime fetchedAt; +} + +/// Última lista da microárea no aparelho (RF08), na base SQLCipher das visitas. +/// +/// Política em `spec/lgpd_design.md` §5.7: a lista pertence a um dono +/// (`userId|microAreaId`); [read] de **outro** dono apaga o cache em vez de +/// só escondê-lo — dado de outro território não fica no disco esperando uma +/// confusão. +class MicroAreaCacheStore { + MicroAreaCacheStore({ + required DatabaseKeyStore keyStore, + this.databaseName = 'sinalacs_acs.db', + this.allowUnencryptedForTesting = false, + }) : _keyStore = keyStore; + + static const _tabela = 'micro_area_cache'; + static const _meta = 'micro_area_cache_meta'; + static const _chaveDono = 'owner'; + static const _chaveData = 'fetched_at'; + + final DatabaseKeyStore _keyStore; + final String databaseName; + final bool allowUnencryptedForTesting; + Database? _database; + + Future _open() async { + final existente = _database; + if (existente != null && existente.isOpen) return existente; + final passphrase = await _keyStore.readOrCreate(); + return _database = await EncryptedLocalDatabase.open( + databaseName: databaseName, + passphrase: passphrase, + allowUnencryptedForTesting: allowUnencryptedForTesting, + ); + } + + Future read({required String owner}) async { + final db = await _open(); + final meta = { + for (final linha in await db.query(_meta)) linha['key'] as String: linha['value'] as String, + }; + final donoGravado = meta[_chaveDono]; + final data = meta[_chaveData]; + if (donoGravado == null || data == null) return null; + if (donoGravado != owner) { + await clear(); + return null; + } + + final linhas = await db.query(_tabela, orderBy: 'rowid'); + return CachedMicroArea( + fetchedAt: DateTime.parse(data), + patients: [ + for (final linha in linhas) + MicroAreaPatient( + patientId: linha['patient_id'] as String, + name: linha['name'] as String, + isChronic: (linha['is_chronic'] as int) == 1, + chronicConditions: (jsonDecode(linha['chronic_conditions'] as String) as List).cast(), + ), + ], + ); + } + + Future write({ + required String owner, + required List patients, + required DateTime at, + }) async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + for (final p in patients) { + await txn.insert(_tabela, { + 'patient_id': p.patientId, + 'name': p.name, + 'is_chronic': p.isChronic ? 1 : 0, + 'chronic_conditions': jsonEncode(p.chronicConditions), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await txn.insert(_meta, {'key': _chaveDono, 'value': owner}, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.insert(_meta, {'key': _chaveData, 'value': at.toUtc().toIso8601String()}, + conflictAlgorithm: ConflictAlgorithm.replace); + }); + } + + Future clear() async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + await txn.delete(_meta); + }); + } +} diff --git a/apps/acs/lib/core/services/micro_area_directory.dart b/apps/acs/lib/core/services/micro_area_directory.dart new file mode 100644 index 00000000..97c358f7 --- /dev/null +++ b/apps/acs/lib/core/services/micro_area_directory.dart @@ -0,0 +1,91 @@ +import 'dart:developer' as developer; + +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +/// Validade do cache da microárea (spec/lgpd_design.md §5.7). +const microAreaCacheMaxAge = Duration(hours: 72); + +class MicroAreaSnapshot { + const MicroAreaSnapshot({required this.patients, required this.fetchedAt, required this.fromCache}); + + final List patients; + final DateTime fetchedAt; + + /// `true` quando a lista veio do aparelho porque a central não respondeu. + final bool fromCache; +} + +/// Lista da microárea com cache no aparelho (RF08). +/// +/// Regra única, em um lugar só: o cache entra **apenas** no lugar de uma falha +/// recuperável (`isRecoverable`) e **apenas** para o mesmo `userId|microAreaId` +/// e dentro da validade. Recusa do servidor (`isRecoverable: false`) sobe como +/// veio — um território negado não pode ser contornado por um cache antigo. +class MicroAreaDirectory { + MicroAreaDirectory({ + required Future> Function() fetch, + required AuthSession? Function() session, + required MicroAreaCacheStore store, + DateTime Function()? clock, + this.maxAge = microAreaCacheMaxAge, + }) : _fetch = fetch, + _session = session, + _store = store, + _clock = clock ?? DateTime.now; + + final Future> Function() _fetch; + final AuthSession? Function() _session; + final MicroAreaCacheStore _store; + final DateTime Function() _clock; + final Duration maxAge; + + String? _owner() { + final s = _session(); + if (s == null || s.microAreaId == null) return null; + return '${s.userId}|${s.microAreaId}'; + } + + Future load() async { + final owner = _owner(); + try { + final fresh = await _fetch(); + final at = _clock().toUtc(); + if (owner != null) { + try { + await _store.write(owner: owner, patients: fresh, at: at); + } catch (error, stack) { + // Gravar o cache é conforto; nunca derruba a lista que acabou de chegar. + developer.log('não foi possível gravar o cache da microárea', + name: 'sinalacs.acs.micro_area_directory', error: error, stackTrace: stack); + } + } + return MicroAreaSnapshot(patients: fresh, fetchedAt: at, fromCache: false); + } on BackendFailure catch (falha) { + if (!falha.isRecoverable || owner == null) rethrow; + final guardado = await _lerSemFalhar(owner); + if (guardado == null) rethrow; + if (_clock().toUtc().difference(guardado.fetchedAt) > maxAge) { + await _apagarSemFalhar(); + rethrow; + } + return MicroAreaSnapshot(patients: guardado.patients, fetchedAt: guardado.fetchedAt, fromCache: true); + } + } + + Future _lerSemFalhar(String owner) async { + try { + return await _store.read(owner: owner); + } catch (_) { + return null; // cache ilegível = sem cache; a falha da rede é a que importa + } + } + + Future _apagarSemFalhar() async { + try { + await _store.clear(); + } catch (_) {} + } +} diff --git a/apps/acs/lib/core/services/micro_area_directory_factory.dart b/apps/acs/lib/core/services/micro_area_directory_factory.dart new file mode 100644 index 00000000..515d2dd3 --- /dev/null +++ b/apps/acs/lib/core/services/micro_area_directory_factory.dart @@ -0,0 +1,19 @@ +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; + +/// Monta o diretório da microárea sobre o backend e a base SQLCipher do aparelho. +/// +/// [cacheStore] existe só para um teste inspecionar o que foi gravado; em +/// produção a chamada não passa nada e usa o padrão, respaldado pelo +/// Keystore/Keychain. +MicroAreaDirectory buildMicroAreaDirectory({ + required AcsBackend backend, + MicroAreaCacheStore? cacheStore, +}) => + MicroAreaDirectory( + fetch: backend.listPatients, + session: () => backend.session, + store: cacheStore ?? MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore()), + ); diff --git a/apps/acs/lib/main.dart b/apps/acs/lib/main.dart index 67d92960..47ac79e0 100644 --- a/apps/acs/lib/main.dart +++ b/apps/acs/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show rootBundle; import 'package:sinalacs_acs/app/app.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory_factory.dart'; /// Sobe o app do ACS. /// @@ -45,7 +46,13 @@ Future main({String? defaultHost}) async { // O cliente é construído aqui, e não dentro de `SinalAcsApp`, porque só aqui // a CA já foi lida: `BackendConfig` não importa `rootBundle` de propósito — // `tool/live_check.dart` o importa fora do Flutter, onde `dart:ui` não existe. - runApp(SinalAcsApp(backend: backend)); + // + // O cache da microárea (RF08) também nasce aqui: é a única montagem que usa o + // Keystore do aparelho. + runApp(SinalAcsApp( + backend: backend, + microAreaDirectory: buildMicroAreaDirectory(backend: backend), + )); } /// Os bytes da CA de desenvolvimento do RPC, ou `null` quando ela não pode ser diff --git a/apps/acs/test/encrypted_database_test.dart b/apps/acs/test/encrypted_database_test.dart index 4fd8daa1..92a7fdbe 100644 --- a/apps/acs/test/encrypted_database_test.dart +++ b/apps/acs/test/encrypted_database_test.dart @@ -245,6 +245,45 @@ CREATE TABLE IF NOT EXISTS offline_visits ( expect(linhas.single['rejection_reason'], isNull); await atual.close(); }); + + test('a v5 ganha as tabelas do cache da microárea sem perder a visita pendente', () async { + sqfliteFfiInit(); + final legado = await databaseFactoryFfi.openDatabase( + await EncryptedLocalDatabase.pathFor(nome), + options: OpenDatabaseOptions( + version: 5, + onCreate: (db, version) async { + await db.execute(EncryptedLocalDatabase.createOfflineVisits); + await db.execute(EncryptedLocalDatabase.createSyncCursor); + }, + ), + ); + await legado.insert('offline_visits', { + 'local_id': '00000000-0000-4000-8000-00000000000c', + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026).toIso8601String(), + 'version': 1, + }); + await legado.close(); + + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + + final novas = await atual.rawQuery( + "SELECT name FROM sqlite_master WHERE name IN ('micro_area_cache','micro_area_cache_meta')", + ); + expect(novas, hasLength(2)); + expect(await atual.query('offline_visits'), hasLength(1)); + expect(EncryptedLocalDatabase.schemaVersion, 6); + await atual.close(); + }); }); group('SqlCipherVisitStore', () { diff --git a/apps/acs/test/micro_area_cache_store_test.dart b/apps/acs/test/micro_area_cache_store_test.dart new file mode 100644 index 00000000..8734e0b3 --- /dev/null +++ b/apps/acs/test/micro_area_cache_store_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; + +/// Roda na VM, sem SQLCipher (`allowUnencryptedForTesting`): prova a lógica de +/// persistência e de dono, não a criptografia (isso vive em `integration_test/`). +MicroAreaPatient _paciente(int n, {bool cronico = false}) => MicroAreaPatient( + patientId: syntheticPatientId(n), + name: 'Paciente Sintético $n', + isChronic: cronico, + chronicConditions: cronico ? ['hipertensão', 'diabetes'] : const [], + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const nome = 'micro_area_cache_test.db'; + late MicroAreaCacheStore store; + + setUp(() async { + await EncryptedLocalDatabase.deleteDatabaseFile(nome); + store = MicroAreaCacheStore( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + ); + }); + tearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(nome)); + + test('sem nada gravado, read devolve null', () async { + expect(await store.read(owner: 'u1|m1'), isNull); + }); + + test('grava e lê de volta a lista, a data e as condições crônicas', () async { + final em = DateTime.utc(2026, 10, 2, 8); + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2, cronico: true)], at: em); + + final lido = await store.read(owner: 'u1|m1'); + + expect(lido!.fetchedAt, em); + expect(lido.patients.map((p) => p.patientId), [syntheticPatientId(1), syntheticPatientId(2)]); + expect(lido.patients.last.isChronic, isTrue); + expect(lido.patients.last.chronicConditions, ['hipertensão', 'diabetes']); + }); + + test('outro dono apaga e não serve', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + + expect(await store.read(owner: 'u2|m1'), isNull, reason: 'outro usuário'); + expect(await store.read(owner: 'u1|m1'), isNull, reason: 'o cache do dono antigo foi APAGADO, não só escondido'); + }); + + test('regravar substitui a lista inteira (paciente que saiu da microárea some)', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2)], at: DateTime.utc(2026, 10, 2)); + await store.write(owner: 'u1|m1', patients: [_paciente(2)], at: DateTime.utc(2026, 10, 3)); + + final lido = await store.read(owner: 'u1|m1'); + expect(lido!.patients.map((p) => p.patientId), [syntheticPatientId(2)]); + }); + + test('clear apaga tudo', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + await store.clear(); + expect(await store.read(owner: 'u1|m1'), isNull); + }); +} diff --git a/apps/acs/test/micro_area_cache_ui_test.dart b/apps/acs/test/micro_area_cache_ui_test.dart new file mode 100644 index 00000000..87fc78f8 --- /dev/null +++ b/apps/acs/test/micro_area_cache_ui_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'login_flow_test.dart' show entrar; +import 'support/fakes.dart'; +import 'support/memory_micro_area_store.dart'; + +void main() { + guardaDoMain(); + late FakeAcsBackend backend; + + setUp(() { + backend = FakeAcsBackend() + ..patients = [ + MicroAreaPatient( + patientId: syntheticPatientId(5), + name: 'Fulano de Tal', + isChronic: false, + chronicConditions: const [], + ), + ]; + }); + + Future abrirVisita(WidgetTester tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + visitQueue: OfflineVisitQueue(), + microAreaDirectory: MicroAreaDirectory( + fetch: backend.listPatients, + session: () => backend.session, + store: MemoryMicroAreaStore(), + ), + )); + await entrar(tester); + await tester.pumpAndSettle(); + } + + testWidgets('sem rede, a tela de visita serve o cache e avisa de quando é', (tester) async { + await abrirVisita(tester); + // A primeira carga (ao abrir o painel) gravou a lista. + backend.listPatientsFailure = const BackendFailure('Sem conexão.'); + + await tester.tap(find.text('Visita')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('micro_area_cache_notice')), findsOneWidget); + expect(find.byKey(const Key('patient_picker')), findsOneWidget); + expect(find.text('Fulano de Tal'), findsOneWidget); + expect(find.byKey(const Key('patient_directory_error')), findsNothing); + }); + + testWidgets('recusa do servidor não cai no cache: mostra o erro, sem aviso', (tester) async { + await abrirVisita(tester); + backend.listPatientsFailure = const BackendFailure('Sessão inválida.', isRecoverable: false); + + await tester.tap(find.text('Visita')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('micro_area_cache_notice')), findsNothing); + expect(find.byKey(const Key('patient_directory_error')), findsOneWidget); + expect(find.text('Sessão inválida.'), findsOneWidget); + }); +} + +// O padrão de `SinalAcsApp` é sem cache (o Keystore não existe no `flutter test`): +// quem liga o cache de produção é o `main.dart`, e esta guarda impede que ele +// perca a ligação sem ninguém notar. +void guardaDoMain() { + test('o main.dart liga o cache da microárea de produção', () { + final fonte = File('lib/main.dart').readAsStringSync(); + expect(fonte, contains('microAreaDirectory: buildMicroAreaDirectory(backend: backend)')); + }); +} diff --git a/apps/acs/test/micro_area_directory_test.dart b/apps/acs/test/micro_area_directory_test.dart new file mode 100644 index 00000000..d4852244 --- /dev/null +++ b/apps/acs/test/micro_area_directory_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/memory_micro_area_store.dart'; + +AuthSession _sessao({String userId = 'u1', String? microAreaId = 'm1'}) => AuthSession( + accessToken: 't', + tokenType: 'Bearer', + userId: userId, + role: 'acs', + microAreaId: microAreaId, + expiresAt: DateTime.utc(2030), + ); + +MicroAreaPatient _p(int n) => + MicroAreaPatient(patientId: syntheticPatientId(n), name: 'P$n', isChronic: false, chronicConditions: const []); + +void main() { + late MemoryMicroAreaStore store; + late DateTime agora; + late AuthSession? sessao; + late Future> Function() busca; + + MicroAreaDirectory diretorio() => MicroAreaDirectory( + fetch: () => busca(), + session: () => sessao, + store: store, + clock: () => agora, + ); + + setUp(() { + store = MemoryMicroAreaStore(); + agora = DateTime.utc(2026, 10, 2, 8); + sessao = _sessao(); + busca = () async => [_p(1), _p(2)]; + }); + + test('com rede: devolve a lista fresca e grava', () async { + final r = await diretorio().load(); + + expect(r.fromCache, isFalse); + expect(r.patients, hasLength(2)); + expect(store.patients, hasLength(2)); + expect(store.owner, 'u1|m1'); + }); + + test('sem rede, com cache do mesmo dono: serve o cache e diz a data', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 5)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + final r = await diretorio().load(); + + expect(r.fromCache, isTrue); + expect(r.patients, hasLength(2)); + expect(r.fetchedAt, DateTime.utc(2026, 10, 2, 8)); + }); + + test('sem rede e sem cache: a falha original sobe', () async { + busca = () async => throw const BackendFailure('Sem conexão.'); + await expectLater(diretorio().load(), throwsA(isA())); + }); + + test('recusa do servidor NUNCA cai no cache', () async { + await diretorio().load(); + busca = () async => throw const BackendFailure('Sessão inválida.', isRecoverable: false); + + await expectLater(diretorio().load(), throwsA(isA())); + }); + + test('outro dono apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(userId: 'u2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + await expectLater(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull, reason: 'o cache do outro usuário foi apagado'); + }); + + test('outra microárea apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(microAreaId: 'm2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + await expectLater(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('vencido não serve e é apagado', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 73)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + await expectLater(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('exatamente 72 h ainda serve', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 72)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect((await diretorio().load()).fromCache, isTrue); + }); + + test('falha ao gravar o cache não derruba a lista fresca', () async { + store.falhaAoGravar = true; + final r = await diretorio().load(); + expect(r.patients, hasLength(2)); + expect(r.fromCache, isFalse); + }); + + test('sem sessão não há dono: nada é lido nem gravado', () async { + sessao = null; + final r = await diretorio().load(); + expect(r.fromCache, isFalse); + expect(store.owner, isNull); + }); + + test('lista vazia fresca substitui o cache (a microárea esvaziou)', () async { + await diretorio().load(); + busca = () async => []; + await diretorio().load(); + expect(store.patients, isEmpty); + }); +} diff --git a/apps/acs/test/support/memory_micro_area_store.dart b/apps/acs/test/support/memory_micro_area_store.dart new file mode 100644 index 00000000..be753662 --- /dev/null +++ b/apps/acs/test/support/memory_micro_area_store.dart @@ -0,0 +1,42 @@ +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +/// Store em memória: o que se testa aqui é a REGRA (quando serve cache), não o SQLite. +class MemoryMicroAreaStore implements MicroAreaCacheStore { + @override + String get databaseName => 'em_memoria'; + @override + bool get allowUnencryptedForTesting => true; + + String? owner; + List? patients; + DateTime? at; + int clears = 0; + bool falhaAoGravar = false; + + @override + Future read({required String owner}) async { + if (this.owner == null) return null; + if (this.owner != owner) { + await clear(); + return null; + } + return CachedMicroArea(patients: patients!, fetchedAt: at!); + } + + @override + Future write({required String owner, required List patients, required DateTime at}) async { + if (falhaAoGravar) throw StateError('disco cheio'); + this.owner = owner; + this.patients = patients; + this.at = at; + } + + @override + Future clear() async { + clears++; + owner = null; + patients = null; + at = null; + } +} diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 3d3bfa6d..74505ffb 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -133,6 +133,11 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `city` | `text` | Dado Territorial Público | String de município | Município de lotação da UBS. | | | `state` | `text` | Dado Territorial Público | String UF | Estado da federação de lotação da UBS. | | | `contactPhone` | `text` (nullable) | Dado da unidade (não pessoal) | Telefone para escalonamento (RF13) | Nullable: sem telefone, o app avisa e não liga. Entregue ao ACS por `ubs.myContact`. | +| **micro_area_cache** (local, aparelho do ACS, sob SQLCipher) | `patient_id` | `text` | Pseudonimizado | UUID do paciente | Chave da linha. Cache RF08 (`spec/lgpd_design.md` §5.11); só no aparelho, nunca enviado. | +| | `name` | `text` | Identificável Pessoal | Nome do paciente em texto claro **dentro** da base SQLCipher | Mesma classificação de `patients.name`; no aparelho, sob SQLCipher (INV-04). Validade de 72 h, por dono. | +| | `is_chronic` | `integer` | Dado Sensível de Saúde (derivado) | 0/1 | Mesma classificação de `patients.isChronic`. | +| | `chronic_conditions` | `text` | **Dado Sensível de Saúde** | Lista JSON de condições crônicas | Mesma classificação de `patients.chronicConditions`; no aparelho, sob SQLCipher. | +| **micro_area_cache_meta** (local, aparelho do ACS, sob SQLCipher) | `owner` / `fetched_at` | `text` | Metadado Técnico | Linhas `key`/`value`: dono (`userId\|microAreaId`) e data do download | Decide a quem o cache serve e quando vence. Não identifica titular. | | **serverpod_query_log** | `query` | `text` | **Risco Crítico de Fuga Indireta** | Texto SQL completo de queries lentas/falhas | **Atualizado:** ainda crítico para `name`/`emergencyContact` (texto claro). Para `chronicConditions`/`answers`/`notes`, a cifragem acontece na aplicação **antes** de a query ser montada (§2.3), então o valor que chegaria a esta tabela — se o log for reativado — já é ciphertext, não texto claro; risco rebaixado para esses três campos especificamente (ver §3.2.2). | | | Demais colunas | Vários | Metadados de Sistema | Timestamps, durações e IDs numéricos | Métricas de telemetria e depuração de queries do banco de dados. | | **serverpod_message_log** | `error` / `stackTrace` | `text` | Risco Moderado de Vazamento | Dump de exceções não tratadas | Risco de exposição de payloads RPC contendo dados clínicos sensíveis ou identificadores em stacktraces não sanitizados. | diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 43adb172..216e13a9 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -604,6 +604,20 @@ CPFs de documentação (como faz `seed_cpf_hashes.dart` para a stack de desenvol | **Implementação** | Análise estática de código (SAST) no CI/CD; testes de penetração anuais; scanner de vulnerabilidades em dependências; processo de disclosure responsável; atualização regular de bibliotecas e frameworks. | | **Riscos Mitigados** | Exploração de vulnerabilidades conhecidas, zero-days, falhas de segurança introduzidas por dependências. | +### 5.11 Cache local da microárea (RF08) + +| | | +|---|---| +| **Decisão** | O ACS guarda no aparelho a última lista da microárea (`patients.listMicroArea`) para registrar visita **sem rede**. | +| **O que guarda** | Só o que a tela de visita já mostra: `patientId`, nome, `isChronic` e condições crônicas — o mesmo conjunto da retenção no aparelho (§5.6). Nada de contato de emergência, endereço, histórico ou triagem. | +| **Onde** | Tabelas `micro_area_cache` e `micro_area_cache_meta` na **mesma base SQLCipher** das visitas offline (INV-04; chave no Keystore, §5.1). | +| **Dono** | A lista pertence a `userId\|microAreaId` da sessão que a baixou. Outro usuário ou outra microárea **apaga** o cache e não o serve (RNF06). | +| **Validade** | 72 horas desde o último download bem-sucedido. Vencida, **não é usada e é apagada**. | +| **Quando entra** | Só no lugar de uma falha **recuperável** de rede. Uma recusa do servidor (sessão inválida, território negado, `isRecoverable: false`) nunca cai no cache. | +| **Transparência** | A tela diz que a lista é do cache e de quando. | +| **Retenção** | Some pela validade, pela troca de dono e com a desinstalação; nunca é enviada a lugar nenhum. | +| **Se for revisto** | Reduzir `microAreaCacheMaxAge` ou apagar a tabela v6 é migração aditiva reversível. | + --- ## 6. Direitos dos Titulares dos Dados diff --git a/spec/validation_report.md b/spec/validation_report.md index 2353f156..95be8d74 100644 --- a/spec/validation_report.md +++ b/spec/validation_report.md @@ -81,7 +81,7 @@ cliente · **`parcial`** = existe, mas alimentado por dado fabricado · | RF05 | Painel de status da solicitação | **backend** | `alerts.statusFor` lê o status do alerta mais recente do paciente autenticado pelo próprio token — sem `patientId` como parâmetro, então um token só pode ler o próprio status (INV-05). A tela deixou de ser `const` e consome o endpoint (fecha L-03). | | RF06 | Lembretes de saúde | **ausente** | `RemindersScreen` tem lista fixa; salvar descarta. Sem `flutter_local_notifications`. | | RF07 | Login institucional (matrícula/senha) | **backend + app** | `auth.loginInstitutional` verifica a senha com Argon2id contra `user_credentials`, bloqueia após 5 tentativas por 15 min (F6) e audita cada desfecho; o app ACS envia o que a pessoa digita. A credencial nasce do seed de desenvolvimento (`bin/seed_acs_credentials.dart`, o único caminho pelo qual uma credencial passa a existir — depois dele a tabela só é escrita para contar tentativas, em `OrmAcsCredentialStore` —, e que se recusa a rodar fora de `APP_ENV=development`), então um deploy que não rode esse seed sobe **sem nenhum caminho de login**. MFA/TOTP e refresh token seguem ausentes por decisão de escopo — ver `docs/superpowers/plans/2026-09-18-rf07-login-institucional-acs.md`. | -| RF08 | Territorialização (cache da microárea) | **parcial** | `patients.listMicroArea` é real, territorializado, e a tela "Área" agora mostra o número real de pacientes (L-06 fechado). Continua parcial: a chamada é ao vivo a cada abertura/ciclo periódico, não um cache `sqflite` persistido em disco que sobrevive offline — esse é o trabalho que falta para RF08 completo. | +| RF08 | Territorialização (cache da microárea) | **backend + app** | `patients.listMicroArea` é real e territorializado pelo token. O app do ACS guarda a última lista na base SQLCipher do aparelho (`micro_area_cache`, schema v6) por dono `userId\|microAreaId`, com validade de 72 h, e só a serve no lugar de uma falha **recuperável** de rede, avisando de quando é. Decisão em `spec/lgpd_design.md` §5.11. Provado no emulador: o arquivo no disco não contém o nome do paciente em claro. | | RF09 | Dashboard de priorização dinâmica | **backend** | Fila real alimentada por MQTT, ordenada por risco e idade; valida rejeição de alerta de outra microárea. | | RF10 | Mapa interativo | **parcial** | Coordenadas são **fabricadas** a partir do hash — ver L-05. | | RF11 | Registro rápido de visitas offline-first | **backend** | `visits.sync` com dedupe por `localId`, versionamento, conflito e território. Fila SQLCipher no dispositivo. | @@ -93,7 +93,7 @@ cliente · **`parcial`** = existe, mas alimentado por dado fabricado · | RF17 | Logs de auditoria e conformidade | **backend** | `audit_logs` encadeado por HMAC; gravou `granted` e `denied_territory` nesta validação; cadeia verificada íntegra. | | RF18 | Dark mode nativo | **app-only** | Tema único dark nos três apps, com matriz de contraste testada. | -**Contagem:** 7 `backend` · 2 `backend + app` · 1 `app-only` · 4 `parcial` · 4 `ausente`. +**Contagem:** 7 `backend` · 3 `backend + app` · 1 `app-only` · 3 `parcial` · 4 `ausente`. ### Requisitos não funcionais @@ -271,7 +271,7 @@ mesmo vale para a TMRAV segmentada por risco, que é a métrica *North Star* do contagem real de pacientes da microárea, atualizada ao abrir a tela e em ciclo periódico. Ver `docs/superpowers/plans/2026-09-18-sync-periodica-rf05-l06.md`. RF08 - continua `parcial` — ver linha RF08 acima. + foi fechado depois, com cache persistido e cifrado — ver linha RF08 acima. - **L-07 · ~~Escalonamento SAMU não funciona.~~ (SAMU fechado em 2026-10-01; a UBS continua sem contato)** O botão mais crítico da UI de emergência é um snackbar. - **L-08 · ~~RPC sem TLS. O backend fala HTTP puro na 8080; só o broker usa From d6718dd285af09928b06382820bcb56741c9710c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:19:37 -0400 Subject: [PATCH 35/43] feat(auth): MFA por TOTP no login institucional do ACS (RF07, LGPD-RT06), com replay barrado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Totp (RFC 6238, SHA-1, 6 dígitos, 30 s, janela ±1) com os vetores da RFC e comparação sem saída antecipada. - Segredo cifrado com a chave dos dados clínicos (HealthCipherTotpVault); quatro colunas nullable em user_credentials (migração aditiva). - auth.loginInstitutional ganha totpCode; MfaRequiredException e MfaEnrollmentRequiredException; auth.beginTotpEnrollment e auth.confirmTotpEnrollment, públicos por desenho e sujeitos ao bloqueio. - O passo aceito só avança no UPDATE: o mesmo código não entra duas vezes. - REQUIRE_ACS_MFA: ligado fora de development; false na stack local e no e2e. - Teste de integração prova begin → confirm → login e o replay contra Postgres. --- .env.example | 6 + backend/CLAUDE.md | 2 + .../protocol/api/totp_enrollment_start.dart | 84 + .../lib/src/protocol/client.dart | 135 +- .../mfa_enrollment_required_exception.dart | 65 + .../exceptions/mfa_required_exception.dart | 64 + .../lib/src/protocol/protocol.dart | 625 +-- .../lib/src/protocol/user_credential.dart | 59 + .../auth/institutional_auth_service.dart | 203 +- .../lib/src/application/auth/totp.dart | 89 + .../application/auth/totp_secret_vault.dart | 16 + .../lib/src/config/app_config.dart | 17 + .../lib/src/endpoints/auth_endpoint.dart | 28 + .../generated/api/totp_enrollment_start.dart | 94 + .../lib/src/generated/endpoints.dart | 62 + .../mfa_enrollment_required_exception.dart | 76 + .../exceptions/mfa_required_exception.dart | 75 + .../lib/src/generated/protocol.dart | 701 ++-- .../lib/src/generated/protocol.yaml | 2 + .../lib/src/generated/user_credential.dart | 118 + .../crypto/health_cipher_totp_vault.dart | 28 + .../database/orm_acs_credential_store.dart | 71 +- .../models/api/totp_enrollment_start.spy.yaml | 6 + ...mfa_enrollment_required_exception.spy.yaml | 5 + .../mfa_required_exception.spy.yaml | 5 + .../lib/src/models/user_credential.spy.yaml | 8 + .../lib/src/runtime/alert_runtime.dart | 18 +- .../20261002161303717/definition.json | 3451 +++++++++++++++++ .../20261002161303717/definition.sql | 719 ++++ .../20261002161303717/definition_project.json | 2115 ++++++++++ .../20261002161303717/migration.json | 53 + .../20261002161303717/migration.sql | 28 + .../migrations/migration_registry.txt | 1 + .../integration/institutional_mfa_test.dart | 249 ++ .../test_tools/serverpod_test_tools.dart | 170 +- .../test/unit/app_config_test.dart | 26 + .../test/unit/endpoint_auth_posture_test.dart | 7 + .../unit/institutional_auth_mfa_test.dart | 267 ++ .../sinalacs_server/test/unit/totp_test.dart | 80 + docker-compose.e2e.yml | 3 + docker-compose.yml | 3 + spec/lgpd_data_audit.md | 4 + 42 files changed, 9106 insertions(+), 732 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart create mode 100644 backend/sinalacs_server/lib/src/application/auth/totp.dart create mode 100644 backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart create mode 100644 backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart create mode 100644 backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart create mode 100644 backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml create mode 100644 backend/sinalacs_server/migrations/20261002161303717/definition.json create mode 100644 backend/sinalacs_server/migrations/20261002161303717/definition.sql create mode 100644 backend/sinalacs_server/migrations/20261002161303717/definition_project.json create mode 100644 backend/sinalacs_server/migrations/20261002161303717/migration.json create mode 100644 backend/sinalacs_server/migrations/20261002161303717/migration.sql create mode 100644 backend/sinalacs_server/test/integration/institutional_mfa_test.dart create mode 100644 backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart create mode 100644 backend/sinalacs_server/test/unit/totp_test.dart diff --git a/.env.example b/.env.example index 362e16fd..f6db43c3 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,12 @@ APP_ENV=development # autenticação institucional. ENABLE_DEV_LOGIN=true +# MFA por TOTP (RFC 6238) no login institucional do ACS (RF07). Com true, o ACS +# sem MFA ativa não entra: precisa ativá-la antes (auth.beginTotpEnrollment). +# Em produção o padrão é `true` (variável ausente = ligada fora de development); +# este default é só da stack local. +REQUIRE_ACS_MFA=false + # Gateway de SMS do login passwordless. `log` (padrão em development) escreve o # código no log do processo em vez de enviar SMS. Em produção é obrigatório # apontar para um gateway real — nenhum provedor está escolhido ainda, então diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 63a6b75e..89fc6423 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -40,6 +40,8 @@ MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fro Outside `development`, boot fails fast when `JWT_SECRET`, `AUDIT_CHAIN_SECRET` or `CPF_HASH_PEPPER` is absent, empty, blank, or equal to its own development fallback (`AppConfig.developmentJwtSecret` / `AppConfig.developmentAuditChainSecret` / `AppConfig.developmentCpfHashPepper`) — those fallbacks live in versioned code, so they're public, and `JWT_SECRET` signs tokens that carry role and micro-area, `AUDIT_CHAIN_SECRET` keys the `audit_logs` hash chain while `CPF_HASH_PEPPER` keys the HMAC over the CPF and over the OTP code. They are deliberately independent secrets — rotating one must not affect the others. The shared validation logic is `_resolveSecret`, covered by `test/unit/app_config_test.dart`; `AppConfig.fromMap()` exists so the rules are testable without touching `Platform.environment`. +`REQUIRE_ACS_MFA` (RF07, MFA por TOTP) — ausente, vale `true` em todo ambiente que não seja `development` e `false` em `development` (`AppConfig.resolveRequireAcsMfa`); presente, só a string exata `true` liga. `docker-compose.yml` passa `${REQUIRE_ACS_MFA:-false}` (default só da stack local) e `docker-compose.e2e.yml` fixa `"false"` para a jornada do emulador. Ligada, o ACS sem MFA ativa recebe `MfaEnrollmentRequiredException` no `auth.loginInstitutional` e precisa ativá-la por `auth.beginTotpEnrollment` (matrícula + senha → segredo base32 + URI `otpauth://`, gravado como **pendente**) e `auth.confirmTotpEnrollment` (matrícula + senha + primeiro código); os dois são públicos por desenho (não há token antes da MFA) e contam tentativa errada no mesmo bloqueio do login. Com a MFA ativa, a senha certa sem código recebe `MfaRequiredException` (não conta tentativa); código errado conta; o passo de 30 s aceito vai para `user_credentials.totpLastStep` e o `UPDATE` só deixa o passo avançar, o que barra o replay. TOTP é RFC 6238 (SHA-1, 6 dígitos, 30 s, janela ±1, comparação sem saída antecipada) em `application/auth/totp.dart`. O segredo é cifrado por `HealthCipherTotpVault` com a **mesma** chave dos dados clínicos (`HEALTH_DATA_ENCRYPTION_KEY`): rotacionar ou perder essa chave invalida também as MFAs. Não há redefinição pela coordenação ainda: para redefinir a MFA de um ACS, zere as quatro colunas (`totpSecretEncrypted`, `totpKeyVersion`, `totpEnabledAt`, `totpLastStep`) da linha dele em `user_credentials`. + The dev seed (`sinalacs_server/lib/src/infrastructure/database/seeds/development.sql`) is not optional for a running stack: `auth.developmentLogin` issues tokens for fixed UUIDs, and `alerts.patientId` has a foreign key to `patients` — without the seed, `alerts.createRedAlert` fails with a foreign-key violation. In `docker-compose.yml` the `database-seed` service applies it after the server is healthy. The seed runs in **four** steps. `development.sql` is plain SQL over `psql`, so it cannot produce the AES-256-GCM value that `patients.chronicConditionsEncrypted` now holds (Track E, RNF03/INV-04): it inserts the rows with an empty ciphertext, and the `health-data-seed` compose service then runs `bin/seed_health_data.dart`, which reuses `HealthDataCipher` to fill those columns. That service reuses the server image (`sinalacs/server:local`) precisely so both processes derive the key from the same `HEALTH_DATA_ENCRYPTION_KEY`. Pasting a fixed ciphertext into the `.sql` was the alternative, and was rejected because it duplicates the encryption logic outside Dart and breaks silently on any key or algorithm change. The third step is `acs-credential-seed` (RF07): `bin/seed_acs_credentials.dart` derives the Argon2id hash and writes the `user_credentials` row of the seeded ACS, for exactly the same reason — Postgres cannot derive Argon2id either — and a literal hash in the `.sql` was rejected by the same argument. It reuses the server image too, refuses to run unless `APP_ENV=development`, and takes the password from `DEV_ACS_PASSWORD`, which `bootstrap_env.sh` generates per machine and which is never versioned. The fourth step is `cpf-hash-seed` (RF01): `bin/seed_cpf_hashes.dart` computes the HMAC-SHA-256 of each seeded patient's CPF with `CPF_HASH_PEPPER` and writes `users.cpfHash` — the same argument once more, and this time the consequence is a login, not a field: the passwordless login looks the CPF up *by hash*, and `development.sql` **still writes a literal there** (`'development-patient'`, and one per seeded patient — lines ~19-30) that matches nothing, so without this step no seeded patient can log in. Who replaces those literals is the `UPDATE` of this step, on the patient rows only; the `.sql` keeps writing them, which is why anyone checking the file finds them and has to keep reading to find the fix. It reuses the server image as well, and the pepper **must** be the same value the server process gets, or no seeded CPF is ever found. The seeded ACS is deliberately left out of it: `verifyOtp` always issues `role: UserRole.patient`, so a CPF on the ACS row would let the passwordless login mint a *patient* token carrying the ACS's id and micro-area. diff --git a/backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart b/backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart new file mode 100644 index 00000000..7d78a446 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart @@ -0,0 +1,84 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +/// `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +abstract class TotpEnrollmentStart implements _i1.SerializableModel { + TotpEnrollmentStart._({ + required this.secretBase32, + required this.otpauthUri, + }); + + factory TotpEnrollmentStart({ + required String secretBase32, + required String otpauthUri, + }) = _TotpEnrollmentStartImpl; + + factory TotpEnrollmentStart.fromJson(Map jsonSerialization) { + return TotpEnrollmentStart( + secretBase32: jsonSerialization['secretBase32'] as String, + otpauthUri: jsonSerialization['otpauthUri'] as String, + ); + } + + String secretBase32; + + String otpauthUri; + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }); + @override + Map toJson() { + return { + '__className__': 'TotpEnrollmentStart', + 'secretBase32': secretBase32, + 'otpauthUri': otpauthUri, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _TotpEnrollmentStartImpl extends TotpEnrollmentStart { + _TotpEnrollmentStartImpl({ + required String secretBase32, + required String otpauthUri, + }) : super._( + secretBase32: secretBase32, + otpauthUri: otpauthUri, + ); + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }) { + return TotpEnrollmentStart( + secretBase32: secretBase32 ?? this.secretBase32, + otpauthUri: otpauthUri ?? this.otpauthUri, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index ef034f0e..d80258cb 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -19,31 +19,33 @@ import 'package:sinalacs_client/src/protocol/api/alert_status_result.dart' as _i5; import 'package:sinalacs_client/src/protocol/api/development_login_result.dart' as _i6; -import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i7; +import 'package:sinalacs_client/src/protocol/api/totp_enrollment_start.dart' + as _i7; +import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i8; import 'package:sinalacs_client/src/protocol/api/notice_send_result.dart' - as _i8; -import 'package:sinalacs_client/src/protocol/api/enrollment_token_result.dart' as _i9; -import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' +import 'package:sinalacs_client/src/protocol/api/enrollment_token_result.dart' as _i10; -import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' +import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' as _i11; -import 'package:sinalacs_client/src/protocol/api/patient_data_overview.dart' +import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' as _i12; -import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' +import 'package:sinalacs_client/src/protocol/api/patient_data_overview.dart' as _i13; -import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' +import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' as _i14; -import 'package:sinalacs_client/src/protocol/api/terms_change_notice.dart' +import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' as _i15; -import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' +import 'package:sinalacs_client/src/protocol/api/terms_change_notice.dart' as _i16; -import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i17; -import 'package:sinalacs_client/src/protocol/api/ubs_contact.dart' as _i18; +import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' + as _i17; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i18; +import 'package:sinalacs_client/src/protocol/api/ubs_contact.dart' as _i19; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i19; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i20; -import 'protocol.dart' as _i21; + as _i20; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i21; +import 'protocol.dart' as _i22; /// Ciclo do alerta vermelho. /// @@ -134,10 +136,16 @@ class EndpointAuth extends _i1.EndpointRef { /// `AuthenticationFailedException`, com a mensagem que o serviço escolheu: /// mensagem idêntica para matrícula inexistente e senha errada (ver /// `InstitutionalAuthService`). + /// + /// MFA: com a verificação em duas etapas ativa, a senha certa sem + /// [totpCode] recebe `MfaRequiredException` (o app pede o código e repete a + /// chamada); com `REQUIRE_ACS_MFA` ligado e a MFA não ativada, recebe + /// `MfaEnrollmentRequiredException` (o app chama `beginTotpEnrollment`). _i2.Future<_i6.DevelopmentLoginResult> loginInstitutional({ required String matricula, required String password, String? deviceId, + String? totpCode, }) => caller.callServerEndpoint<_i6.DevelopmentLoginResult>( 'auth', 'loginInstitutional', @@ -145,6 +153,35 @@ class EndpointAuth extends _i1.EndpointRef { 'matricula': matricula, 'password': password, 'deviceId': deviceId, + 'totpCode': totpCode, + }, + ); + + /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. + _i2.Future<_i7.TotpEnrollmentStart> beginTotpEnrollment({ + required String matricula, + required String password, + }) => caller.callServerEndpoint<_i7.TotpEnrollmentStart>( + 'auth', + 'beginTotpEnrollment', + { + 'matricula': matricula, + 'password': password, + }, + ); + + /// Confirma a ativação com o primeiro código do autenticador. + _i2.Future confirmTotpEnrollment({ + required String matricula, + required String password, + required String code, + }) => caller.callServerEndpoint( + 'auth', + 'confirmTotpEnrollment', + { + 'matricula': matricula, + 'password': password, + 'code': code, }, ); @@ -260,8 +297,8 @@ class EndpointHealth extends _i1.EndpointRef { @override String get name => 'health'; - _i2.Future<_i7.ServiceHealth> check() => - caller.callServerEndpoint<_i7.ServiceHealth>( + _i2.Future<_i8.ServiceHealth> check() => + caller.callServerEndpoint<_i8.ServiceHealth>( 'health', 'check', {}, @@ -280,12 +317,12 @@ class EndpointNotices extends EndpointAuthenticated { /// consentiram com `segmentedPush`. A microárea vem do token, nunca de /// parâmetro. Auditoria: uma linha `community_notice` com quem enviou e a /// microárea; o texto do aviso nunca entra na trilha. - _i2.Future<_i8.NoticeSendResult> sendSegmented({ + _i2.Future<_i9.NoticeSendResult> sendSegmented({ required String accessToken, required String title, required String message, required String audience, - }) => caller.callServerEndpoint<_i8.NoticeSendResult>( + }) => caller.callServerEndpoint<_i9.NoticeSendResult>( 'notices', 'sendSegmented', { @@ -314,10 +351,10 @@ class EndpointOnboarding extends _i1.EndpointRef { String get name => 'onboarding'; /// Chamado pelo app do ACS. Exige sessão de ACS. - _i2.Future<_i9.EnrollmentTokenResult> generateEnrollmentToken({ + _i2.Future<_i10.EnrollmentTokenResult> generateEnrollmentToken({ required String accessToken, required String patientId, - }) => caller.callServerEndpoint<_i9.EnrollmentTokenResult>( + }) => caller.callServerEndpoint<_i10.EnrollmentTokenResult>( 'onboarding', 'generateEnrollmentToken', { @@ -328,13 +365,13 @@ class EndpointOnboarding extends _i1.EndpointRef { /// Chamado pelo app do paciente. Não exige sessão prévia — é a própria /// conclusão do onboarding que emite a primeira sessão. - _i2.Future<_i10.EnrollmentResult> completeEnrollment({ + _i2.Future<_i11.EnrollmentResult> completeEnrollment({ required String token, required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, required bool termsAccepted, - }) => caller.callServerEndpoint<_i10.EnrollmentResult>( + }) => caller.callServerEndpoint<_i11.EnrollmentResult>( 'onboarding', 'completeEnrollment', { @@ -363,9 +400,9 @@ class EndpointPatients extends EndpointAuthenticated { @override String get name => 'patients'; - _i2.Future> listMicroArea({ + _i2.Future> listMicroArea({ required String accessToken, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'patients', 'listMicroArea', {'accessToken': accessToken}, @@ -398,8 +435,8 @@ class EndpointPatients extends EndpointAuthenticated { /// Painel "Meus Dados" do próprio paciente autenticado (LGPD, /// spec/lgpd_design.md linhas 417/581-595): confirmação de existência de /// tratamento e acesso aos dados pessoais. - _i2.Future<_i12.PatientDataOverview> myData({required String accessToken}) => - caller.callServerEndpoint<_i12.PatientDataOverview>( + _i2.Future<_i13.PatientDataOverview> myData({required String accessToken}) => + caller.callServerEndpoint<_i13.PatientDataOverview>( 'patients', 'myData', {'accessToken': accessToken}, @@ -409,11 +446,11 @@ class EndpointPatients extends EndpointAuthenticated { /// consentimento (LGPD-RF05): uma linha nova em `consent_logs`, nunca a /// edição da anterior. `healthDataProcessing` volta como /// [DataRightsException] — retirá-lo passa pelo pedido de exclusão. - _i2.Future<_i13.PatientConsentRecord> updateConsent({ + _i2.Future<_i14.PatientConsentRecord> updateConsent({ required String accessToken, - required _i14.ConsentPurpose purpose, + required _i15.ConsentPurpose purpose, required bool granted, - }) => caller.callServerEndpoint<_i13.PatientConsentRecord>( + }) => caller.callServerEndpoint<_i14.PatientConsentRecord>( 'patients', 'updateConsent', { @@ -427,9 +464,9 @@ class EndpointPatients extends EndpointAuthenticated { /// por quem entrou por OTP sem passar pelo onboarding, ou aceitou uma versão /// anterior. Só paciente; grava uma linha assinada em `consent_logs` só se a /// versão vigente ainda não foi aceita — repetir devolve a existente. - _i2.Future<_i13.PatientConsentRecord> acceptTermsOfUse({ + _i2.Future<_i14.PatientConsentRecord> acceptTermsOfUse({ required String accessToken, - }) => caller.callServerEndpoint<_i13.PatientConsentRecord>( + }) => caller.callServerEndpoint<_i14.PatientConsentRecord>( 'patients', 'acceptTermsOfUse', {'accessToken': accessToken}, @@ -453,7 +490,7 @@ class EndpointPatients extends EndpointAuthenticated { /// push (RF14). _i2.Future hasGrantedConsent({ required String accessToken, - required _i14.ConsentPurpose purpose, + required _i15.ConsentPurpose purpose, }) => caller.callServerEndpoint( 'patients', 'hasGrantedConsent', @@ -466,9 +503,9 @@ class EndpointPatients extends EndpointAuthenticated { /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), /// ou `null`. Só paciente. Sem leitura de banco e sem linha de auditoria: a /// agenda é uma constante do repositório e nada do titular é lido nem gravado. - _i2.Future<_i15.TermsChangeNotice?> termsChangeNotice({ + _i2.Future<_i16.TermsChangeNotice?> termsChangeNotice({ required String accessToken, - }) => caller.callServerEndpoint<_i15.TermsChangeNotice?>( + }) => caller.callServerEndpoint<_i16.TermsChangeNotice?>( 'patients', 'termsChangeNotice', {'accessToken': accessToken}, @@ -476,9 +513,9 @@ class EndpointPatients extends EndpointAuthenticated { /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. - _i2.Future<_i16.PatientDataSubjectRequestRecord> requestDataDeletion({ + _i2.Future<_i17.PatientDataSubjectRequestRecord> requestDataDeletion({ required String accessToken, - }) => caller.callServerEndpoint<_i16.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i17.PatientDataSubjectRequestRecord>( 'patients', 'requestDataDeletion', {'accessToken': accessToken}, @@ -487,10 +524,10 @@ class EndpointPatients extends EndpointAuthenticated { /// Pedido de correção de um dado (LGPD-RF08). [details] é texto livre do /// titular, gravado cifrado; vazio ou acima de 500 caracteres volta como /// [DataRightsException]. - _i2.Future<_i16.PatientDataSubjectRequestRecord> requestDataCorrection({ + _i2.Future<_i17.PatientDataSubjectRequestRecord> requestDataCorrection({ required String accessToken, required String details, - }) => caller.callServerEndpoint<_i16.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i17.PatientDataSubjectRequestRecord>( 'patients', 'requestDataCorrection', { @@ -517,7 +554,7 @@ class EndpointTriage extends EndpointAuthenticated { @override String get name => 'triage'; - _i2.Future<_i17.TriageResult> evaluate({ + _i2.Future<_i18.TriageResult> evaluate({ required String accessToken, required bool chestPain, required bool difficultyBreathing, @@ -525,7 +562,7 @@ class EndpointTriage extends EndpointAuthenticated { required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i17.TriageResult>( + }) => caller.callServerEndpoint<_i18.TriageResult>( 'triage', 'evaluate', { @@ -548,8 +585,8 @@ class EndpointUbs extends EndpointAuthenticated { @override String get name => 'ubs'; - _i2.Future<_i18.UbsContact> myContact({required String accessToken}) => - caller.callServerEndpoint<_i18.UbsContact>( + _i2.Future<_i19.UbsContact> myContact({required String accessToken}) => + caller.callServerEndpoint<_i19.UbsContact>( 'ubs', 'myContact', {'accessToken': accessToken}, @@ -572,10 +609,10 @@ class EndpointVisits extends EndpointAuthenticated { @override String get name => 'visits'; - _i2.Future> sync({ + _i2.Future> sync({ required String accessToken, - required List<_i20.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i21.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -587,10 +624,10 @@ class EndpointVisits extends EndpointAuthenticated { /// Sincronização central→dispositivo: visitas da microárea do ACS /// autenticado alteradas após `since`, para reconciliar um device que /// ficou offline ou foi reinstalado. - _i2.Future> pull({ + _i2.Future> pull({ required String accessToken, required DateTime since, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'visits', 'pull', { @@ -620,7 +657,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i21.Protocol(), + _i22.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart new file mode 100644 index 00000000..5eef78af --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart @@ -0,0 +1,65 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +/// ativar (auth.beginTotpEnrollment) antes de entrar. +abstract class MfaEnrollmentRequiredException + implements _i1.SerializableException, _i1.SerializableModel { + MfaEnrollmentRequiredException._({required this.message}); + + factory MfaEnrollmentRequiredException({required String message}) = + _MfaEnrollmentRequiredExceptionImpl; + + factory MfaEnrollmentRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaEnrollmentRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaEnrollmentRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaEnrollmentRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaEnrollmentRequiredException(message: $message)'; + } +} + +class _MfaEnrollmentRequiredExceptionImpl + extends MfaEnrollmentRequiredException { + _MfaEnrollmentRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaEnrollmentRequiredException copyWith({String? message}) { + return MfaEnrollmentRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart new file mode 100644 index 00000000..16da530f --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart @@ -0,0 +1,64 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod_client/serverpod_client.dart' as _i1; + +/// O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +/// Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +abstract class MfaRequiredException + implements _i1.SerializableException, _i1.SerializableModel { + MfaRequiredException._({required this.message}); + + factory MfaRequiredException({required String message}) = + _MfaRequiredExceptionImpl; + + factory MfaRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaRequiredException(message: $message)'; + } +} + +class _MfaRequiredExceptionImpl extends MfaRequiredException { + _MfaRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaRequiredException copyWith({String? message}) { + return MfaRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 14a0f754..3d1c6a97 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -31,46 +31,49 @@ import 'api/patient_risk_event.dart' as _i17; import 'api/red_alert_result.dart' as _i18; import 'api/service_health.dart' as _i19; import 'api/terms_change_notice.dart' as _i20; -import 'api/triage_result.dart' as _i21; -import 'api/ubs_contact.dart' as _i22; -import 'api/visit_sync_entry.dart' as _i23; -import 'api/visit_sync_result.dart' as _i24; -import 'audit_log.dart' as _i25; -import 'consent_log.dart' as _i26; -import 'data_subject_request.dart' as _i27; -import 'enrollment_token.dart' as _i28; -import 'enums/alert_status.dart' as _i29; -import 'enums/arrival_method.dart' as _i30; -import 'enums/consent_purpose.dart' as _i31; -import 'enums/data_subject_request_status.dart' as _i32; -import 'enums/data_subject_request_type.dart' as _i33; -import 'enums/risk_level.dart' as _i34; -import 'enums/sync_status.dart' as _i35; -import 'enums/user_role.dart' as _i36; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i37; -import 'exceptions/alert_permission_exception.dart' as _i38; -import 'exceptions/alert_validation_exception.dart' as _i39; -import 'exceptions/authentication_failed_exception.dart' as _i40; -import 'exceptions/data_rights_exception.dart' as _i41; -import 'exceptions/endpoint_disabled_exception.dart' as _i42; -import 'exceptions/enrollment_exception.dart' as _i43; -import 'exceptions/notice_delivery_exception.dart' as _i44; -import 'exceptions/otp_request_exception.dart' as _i45; -import 'micro_area.dart' as _i46; -import 'otp_challenge.dart' as _i47; -import 'patient.dart' as _i48; -import 'push_token.dart' as _i49; -import 'triage_answer.dart' as _i50; -import 'triage_session.dart' as _i51; -import 'ubs.dart' as _i52; -import 'user.dart' as _i53; -import 'user_credential.dart' as _i54; -import 'visit.dart' as _i55; +import 'api/totp_enrollment_start.dart' as _i21; +import 'api/triage_result.dart' as _i22; +import 'api/ubs_contact.dart' as _i23; +import 'api/visit_sync_entry.dart' as _i24; +import 'api/visit_sync_result.dart' as _i25; +import 'audit_log.dart' as _i26; +import 'consent_log.dart' as _i27; +import 'data_subject_request.dart' as _i28; +import 'enrollment_token.dart' as _i29; +import 'enums/alert_status.dart' as _i30; +import 'enums/arrival_method.dart' as _i31; +import 'enums/consent_purpose.dart' as _i32; +import 'enums/data_subject_request_status.dart' as _i33; +import 'enums/data_subject_request_type.dart' as _i34; +import 'enums/risk_level.dart' as _i35; +import 'enums/sync_status.dart' as _i36; +import 'enums/user_role.dart' as _i37; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i38; +import 'exceptions/alert_permission_exception.dart' as _i39; +import 'exceptions/alert_validation_exception.dart' as _i40; +import 'exceptions/authentication_failed_exception.dart' as _i41; +import 'exceptions/data_rights_exception.dart' as _i42; +import 'exceptions/endpoint_disabled_exception.dart' as _i43; +import 'exceptions/enrollment_exception.dart' as _i44; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i45; +import 'exceptions/mfa_required_exception.dart' as _i46; +import 'exceptions/notice_delivery_exception.dart' as _i47; +import 'exceptions/otp_request_exception.dart' as _i48; +import 'micro_area.dart' as _i49; +import 'otp_challenge.dart' as _i50; +import 'patient.dart' as _i51; +import 'push_token.dart' as _i52; +import 'triage_answer.dart' as _i53; +import 'triage_session.dart' as _i54; +import 'ubs.dart' as _i55; +import 'user.dart' as _i56; +import 'user_credential.dart' as _i57; +import 'visit.dart' as _i58; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i56; + as _i59; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i57; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i58; + as _i60; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i61; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -90,6 +93,7 @@ export 'api/patient_risk_event.dart'; export 'api/red_alert_result.dart'; export 'api/service_health.dart'; export 'api/terms_change_notice.dart'; +export 'api/totp_enrollment_start.dart'; export 'api/triage_result.dart'; export 'api/ubs_contact.dart'; export 'api/visit_sync_entry.dart'; @@ -113,6 +117,8 @@ export 'exceptions/authentication_failed_exception.dart'; export 'exceptions/data_rights_exception.dart'; export 'exceptions/endpoint_disabled_exception.dart'; export 'exceptions/enrollment_exception.dart'; +export 'exceptions/mfa_enrollment_required_exception.dart'; +export 'exceptions/mfa_required_exception.dart'; export 'exceptions/notice_delivery_exception.dart'; export 'exceptions/otp_request_exception.dart'; export 'micro_area.dart'; @@ -218,110 +224,119 @@ class Protocol extends _i1.SerializationManager { if (t == _i20.TermsChangeNotice) { return _i20.TermsChangeNotice.fromJson(data) as T; } - if (t == _i21.TriageResult) { - return _i21.TriageResult.fromJson(data) as T; + if (t == _i21.TotpEnrollmentStart) { + return _i21.TotpEnrollmentStart.fromJson(data) as T; } - if (t == _i22.UbsContact) { - return _i22.UbsContact.fromJson(data) as T; + if (t == _i22.TriageResult) { + return _i22.TriageResult.fromJson(data) as T; } - if (t == _i23.VisitSyncEntry) { - return _i23.VisitSyncEntry.fromJson(data) as T; + if (t == _i23.UbsContact) { + return _i23.UbsContact.fromJson(data) as T; } - if (t == _i24.VisitSyncResult) { - return _i24.VisitSyncResult.fromJson(data) as T; + if (t == _i24.VisitSyncEntry) { + return _i24.VisitSyncEntry.fromJson(data) as T; } - if (t == _i25.AuditLog) { - return _i25.AuditLog.fromJson(data) as T; + if (t == _i25.VisitSyncResult) { + return _i25.VisitSyncResult.fromJson(data) as T; } - if (t == _i26.ConsentLog) { - return _i26.ConsentLog.fromJson(data) as T; + if (t == _i26.AuditLog) { + return _i26.AuditLog.fromJson(data) as T; } - if (t == _i27.DataSubjectRequest) { - return _i27.DataSubjectRequest.fromJson(data) as T; + if (t == _i27.ConsentLog) { + return _i27.ConsentLog.fromJson(data) as T; } - if (t == _i28.EnrollmentToken) { - return _i28.EnrollmentToken.fromJson(data) as T; + if (t == _i28.DataSubjectRequest) { + return _i28.DataSubjectRequest.fromJson(data) as T; } - if (t == _i29.AlertStatus) { - return _i29.AlertStatus.fromJson(data) as T; + if (t == _i29.EnrollmentToken) { + return _i29.EnrollmentToken.fromJson(data) as T; } - if (t == _i30.ArrivalMethod) { - return _i30.ArrivalMethod.fromJson(data) as T; + if (t == _i30.AlertStatus) { + return _i30.AlertStatus.fromJson(data) as T; } - if (t == _i31.ConsentPurpose) { - return _i31.ConsentPurpose.fromJson(data) as T; + if (t == _i31.ArrivalMethod) { + return _i31.ArrivalMethod.fromJson(data) as T; } - if (t == _i32.DataSubjectRequestStatus) { - return _i32.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i32.ConsentPurpose) { + return _i32.ConsentPurpose.fromJson(data) as T; } - if (t == _i33.DataSubjectRequestType) { - return _i33.DataSubjectRequestType.fromJson(data) as T; + if (t == _i33.DataSubjectRequestStatus) { + return _i33.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i34.RiskLevel) { - return _i34.RiskLevel.fromJson(data) as T; + if (t == _i34.DataSubjectRequestType) { + return _i34.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i35.SyncStatus) { - return _i35.SyncStatus.fromJson(data) as T; + if (t == _i35.RiskLevel) { + return _i35.RiskLevel.fromJson(data) as T; } - if (t == _i36.UserRole) { - return _i36.UserRole.fromJson(data) as T; + if (t == _i36.SyncStatus) { + return _i36.SyncStatus.fromJson(data) as T; } - if (t == _i37.AlertDispatchUnavailableException) { - return _i37.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i37.UserRole) { + return _i37.UserRole.fromJson(data) as T; } - if (t == _i38.AlertPermissionException) { - return _i38.AlertPermissionException.fromJson(data) as T; + if (t == _i38.AlertDispatchUnavailableException) { + return _i38.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i39.AlertValidationException) { - return _i39.AlertValidationException.fromJson(data) as T; + if (t == _i39.AlertPermissionException) { + return _i39.AlertPermissionException.fromJson(data) as T; } - if (t == _i40.AuthenticationFailedException) { - return _i40.AuthenticationFailedException.fromJson(data) as T; + if (t == _i40.AlertValidationException) { + return _i40.AlertValidationException.fromJson(data) as T; } - if (t == _i41.DataRightsException) { - return _i41.DataRightsException.fromJson(data) as T; + if (t == _i41.AuthenticationFailedException) { + return _i41.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i42.EndpointDisabledException) { - return _i42.EndpointDisabledException.fromJson(data) as T; + if (t == _i42.DataRightsException) { + return _i42.DataRightsException.fromJson(data) as T; } - if (t == _i43.EnrollmentException) { - return _i43.EnrollmentException.fromJson(data) as T; + if (t == _i43.EndpointDisabledException) { + return _i43.EndpointDisabledException.fromJson(data) as T; } - if (t == _i44.NoticeDeliveryException) { - return _i44.NoticeDeliveryException.fromJson(data) as T; + if (t == _i44.EnrollmentException) { + return _i44.EnrollmentException.fromJson(data) as T; } - if (t == _i45.OtpRequestException) { - return _i45.OtpRequestException.fromJson(data) as T; + if (t == _i45.MfaEnrollmentRequiredException) { + return _i45.MfaEnrollmentRequiredException.fromJson(data) as T; } - if (t == _i46.MicroArea) { - return _i46.MicroArea.fromJson(data) as T; + if (t == _i46.MfaRequiredException) { + return _i46.MfaRequiredException.fromJson(data) as T; } - if (t == _i47.OtpChallenge) { - return _i47.OtpChallenge.fromJson(data) as T; + if (t == _i47.NoticeDeliveryException) { + return _i47.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i48.Patient) { - return _i48.Patient.fromJson(data) as T; + if (t == _i48.OtpRequestException) { + return _i48.OtpRequestException.fromJson(data) as T; } - if (t == _i49.PushToken) { - return _i49.PushToken.fromJson(data) as T; + if (t == _i49.MicroArea) { + return _i49.MicroArea.fromJson(data) as T; } - if (t == _i50.TriageAnswer) { - return _i50.TriageAnswer.fromJson(data) as T; + if (t == _i50.OtpChallenge) { + return _i50.OtpChallenge.fromJson(data) as T; } - if (t == _i51.TriageSession) { - return _i51.TriageSession.fromJson(data) as T; + if (t == _i51.Patient) { + return _i51.Patient.fromJson(data) as T; } - if (t == _i52.Ubs) { - return _i52.Ubs.fromJson(data) as T; + if (t == _i52.PushToken) { + return _i52.PushToken.fromJson(data) as T; } - if (t == _i53.User) { - return _i53.User.fromJson(data) as T; + if (t == _i53.TriageAnswer) { + return _i53.TriageAnswer.fromJson(data) as T; } - if (t == _i54.UserCredential) { - return _i54.UserCredential.fromJson(data) as T; + if (t == _i54.TriageSession) { + return _i54.TriageSession.fromJson(data) as T; } - if (t == _i55.Visit) { - return _i55.Visit.fromJson(data) as T; + if (t == _i55.Ubs) { + return _i55.Ubs.fromJson(data) as T; + } + if (t == _i56.User) { + return _i56.User.fromJson(data) as T; + } + if (t == _i57.UserCredential) { + return _i57.UserCredential.fromJson(data) as T; + } + if (t == _i58.Visit) { + return _i58.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -389,134 +404,148 @@ class Protocol extends _i1.SerializationManager { if (t == _i1.getType<_i20.TermsChangeNotice?>()) { return (data != null ? _i20.TermsChangeNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.TriageResult?>()) { - return (data != null ? _i21.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i21.TotpEnrollmentStart?>()) { + return (data != null ? _i21.TotpEnrollmentStart.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i22.UbsContact?>()) { - return (data != null ? _i22.UbsContact.fromJson(data) : null) as T; + if (t == _i1.getType<_i22.TriageResult?>()) { + return (data != null ? _i22.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.VisitSyncEntry?>()) { - return (data != null ? _i23.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.UbsContact?>()) { + return (data != null ? _i23.UbsContact.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.VisitSyncResult?>()) { - return (data != null ? _i24.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.VisitSyncEntry?>()) { + return (data != null ? _i24.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.AuditLog?>()) { - return (data != null ? _i25.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.VisitSyncResult?>()) { + return (data != null ? _i25.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.ConsentLog?>()) { - return (data != null ? _i26.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.AuditLog?>()) { + return (data != null ? _i26.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.DataSubjectRequest?>()) { - return (data != null ? _i27.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i27.ConsentLog?>()) { + return (data != null ? _i27.ConsentLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i28.DataSubjectRequest?>()) { + return (data != null ? _i28.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.EnrollmentToken?>()) { - return (data != null ? _i28.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.EnrollmentToken?>()) { + return (data != null ? _i29.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.AlertStatus?>()) { - return (data != null ? _i29.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.AlertStatus?>()) { + return (data != null ? _i30.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.ArrivalMethod?>()) { - return (data != null ? _i30.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.ArrivalMethod?>()) { + return (data != null ? _i31.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.ConsentPurpose?>()) { - return (data != null ? _i31.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.ConsentPurpose?>()) { + return (data != null ? _i32.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i33.DataSubjectRequestStatus?>()) { return (data != null - ? _i32.DataSubjectRequestStatus.fromJson(data) + ? _i33.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.DataSubjectRequestType?>()) { - return (data != null ? _i33.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i34.DataSubjectRequestType?>()) { + return (data != null ? _i34.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.RiskLevel?>()) { - return (data != null ? _i34.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.RiskLevel?>()) { + return (data != null ? _i35.RiskLevel.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i36.SyncStatus?>()) { + return (data != null ? _i36.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.SyncStatus?>()) { - return (data != null ? _i35.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i37.UserRole?>()) { + return (data != null ? _i37.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.UserRole?>()) { - return (data != null ? _i36.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i38.AlertDispatchUnavailableException?>()) { + return (data != null + ? _i38.AlertDispatchUnavailableException.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i37.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i39.AlertPermissionException?>()) { return (data != null - ? _i37.AlertDispatchUnavailableException.fromJson(data) + ? _i39.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.AlertPermissionException?>()) { + if (t == _i1.getType<_i40.AlertValidationException?>()) { return (data != null - ? _i38.AlertPermissionException.fromJson(data) + ? _i40.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.AlertValidationException?>()) { + if (t == _i1.getType<_i41.AuthenticationFailedException?>()) { return (data != null - ? _i39.AlertValidationException.fromJson(data) + ? _i41.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i42.DataRightsException?>()) { + return (data != null ? _i42.DataRightsException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i43.EndpointDisabledException?>()) { return (data != null - ? _i40.AuthenticationFailedException.fromJson(data) + ? _i43.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.DataRightsException?>()) { - return (data != null ? _i41.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i44.EnrollmentException?>()) { + return (data != null ? _i44.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.EndpointDisabledException?>()) { + if (t == _i1.getType<_i45.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i42.EndpointDisabledException.fromJson(data) + ? _i45.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.EnrollmentException?>()) { - return (data != null ? _i43.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i46.MfaRequiredException?>()) { + return (data != null ? _i46.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.NoticeDeliveryException?>()) { - return (data != null ? _i44.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i47.NoticeDeliveryException?>()) { + return (data != null ? _i47.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.OtpRequestException?>()) { - return (data != null ? _i45.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i48.OtpRequestException?>()) { + return (data != null ? _i48.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.MicroArea?>()) { - return (data != null ? _i46.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.MicroArea?>()) { + return (data != null ? _i49.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.OtpChallenge?>()) { - return (data != null ? _i47.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.OtpChallenge?>()) { + return (data != null ? _i50.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.Patient?>()) { - return (data != null ? _i48.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.Patient?>()) { + return (data != null ? _i51.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.PushToken?>()) { - return (data != null ? _i49.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.PushToken?>()) { + return (data != null ? _i52.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.TriageAnswer?>()) { - return (data != null ? _i50.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.TriageAnswer?>()) { + return (data != null ? _i53.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.TriageSession?>()) { - return (data != null ? _i51.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.TriageSession?>()) { + return (data != null ? _i54.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.Ubs?>()) { - return (data != null ? _i52.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i55.Ubs?>()) { + return (data != null ? _i55.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.User?>()) { - return (data != null ? _i53.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i56.User?>()) { + return (data != null ? _i56.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.UserCredential?>()) { - return (data != null ? _i54.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i57.UserCredential?>()) { + return (data != null ? _i57.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i55.Visit?>()) { - return (data != null ? _i55.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i58.Visit?>()) { + return (data != null ? _i58.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -545,24 +574,24 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i56.MicroAreaPatient>) { + if (t == List<_i59.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i56.MicroAreaPatient>(e)) + .map((e) => deserialize<_i59.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i57.VisitSyncResult>) { + if (t == List<_i60.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i57.VisitSyncResult>(e)) + .map((e) => deserialize<_i60.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i58.VisitSyncEntry>) { + if (t == List<_i61.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i58.VisitSyncEntry>(e)) + .map((e) => deserialize<_i61.VisitSyncEntry>(e)) .toList() as T; } @@ -590,42 +619,45 @@ class Protocol extends _i1.SerializationManager { _i18.RedAlertResult => 'RedAlertResult', _i19.ServiceHealth => 'ServiceHealth', _i20.TermsChangeNotice => 'TermsChangeNotice', - _i21.TriageResult => 'TriageResult', - _i22.UbsContact => 'UbsContact', - _i23.VisitSyncEntry => 'VisitSyncEntry', - _i24.VisitSyncResult => 'VisitSyncResult', - _i25.AuditLog => 'AuditLog', - _i26.ConsentLog => 'ConsentLog', - _i27.DataSubjectRequest => 'DataSubjectRequest', - _i28.EnrollmentToken => 'EnrollmentToken', - _i29.AlertStatus => 'AlertStatus', - _i30.ArrivalMethod => 'ArrivalMethod', - _i31.ConsentPurpose => 'ConsentPurpose', - _i32.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i33.DataSubjectRequestType => 'DataSubjectRequestType', - _i34.RiskLevel => 'RiskLevel', - _i35.SyncStatus => 'SyncStatus', - _i36.UserRole => 'UserRole', - _i37.AlertDispatchUnavailableException => + _i21.TotpEnrollmentStart => 'TotpEnrollmentStart', + _i22.TriageResult => 'TriageResult', + _i23.UbsContact => 'UbsContact', + _i24.VisitSyncEntry => 'VisitSyncEntry', + _i25.VisitSyncResult => 'VisitSyncResult', + _i26.AuditLog => 'AuditLog', + _i27.ConsentLog => 'ConsentLog', + _i28.DataSubjectRequest => 'DataSubjectRequest', + _i29.EnrollmentToken => 'EnrollmentToken', + _i30.AlertStatus => 'AlertStatus', + _i31.ArrivalMethod => 'ArrivalMethod', + _i32.ConsentPurpose => 'ConsentPurpose', + _i33.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i34.DataSubjectRequestType => 'DataSubjectRequestType', + _i35.RiskLevel => 'RiskLevel', + _i36.SyncStatus => 'SyncStatus', + _i37.UserRole => 'UserRole', + _i38.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i38.AlertPermissionException => 'AlertPermissionException', - _i39.AlertValidationException => 'AlertValidationException', - _i40.AuthenticationFailedException => 'AuthenticationFailedException', - _i41.DataRightsException => 'DataRightsException', - _i42.EndpointDisabledException => 'EndpointDisabledException', - _i43.EnrollmentException => 'EnrollmentException', - _i44.NoticeDeliveryException => 'NoticeDeliveryException', - _i45.OtpRequestException => 'OtpRequestException', - _i46.MicroArea => 'MicroArea', - _i47.OtpChallenge => 'OtpChallenge', - _i48.Patient => 'Patient', - _i49.PushToken => 'PushToken', - _i50.TriageAnswer => 'TriageAnswer', - _i51.TriageSession => 'TriageSession', - _i52.Ubs => 'Ubs', - _i53.User => 'User', - _i54.UserCredential => 'UserCredential', - _i55.Visit => 'Visit', + _i39.AlertPermissionException => 'AlertPermissionException', + _i40.AlertValidationException => 'AlertValidationException', + _i41.AuthenticationFailedException => 'AuthenticationFailedException', + _i42.DataRightsException => 'DataRightsException', + _i43.EndpointDisabledException => 'EndpointDisabledException', + _i44.EnrollmentException => 'EnrollmentException', + _i45.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i46.MfaRequiredException => 'MfaRequiredException', + _i47.NoticeDeliveryException => 'NoticeDeliveryException', + _i48.OtpRequestException => 'OtpRequestException', + _i49.MicroArea => 'MicroArea', + _i50.OtpChallenge => 'OtpChallenge', + _i51.Patient => 'Patient', + _i52.PushToken => 'PushToken', + _i53.TriageAnswer => 'TriageAnswer', + _i54.TriageSession => 'TriageSession', + _i55.Ubs => 'Ubs', + _i56.User => 'User', + _i57.UserCredential => 'UserCredential', + _i58.Visit => 'Visit', _ => null, }; } @@ -678,75 +710,81 @@ class Protocol extends _i1.SerializationManager { return 'ServiceHealth'; case _i20.TermsChangeNotice(): return 'TermsChangeNotice'; - case _i21.TriageResult(): + case _i21.TotpEnrollmentStart(): + return 'TotpEnrollmentStart'; + case _i22.TriageResult(): return 'TriageResult'; - case _i22.UbsContact(): + case _i23.UbsContact(): return 'UbsContact'; - case _i23.VisitSyncEntry(): + case _i24.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i24.VisitSyncResult(): + case _i25.VisitSyncResult(): return 'VisitSyncResult'; - case _i25.AuditLog(): + case _i26.AuditLog(): return 'AuditLog'; - case _i26.ConsentLog(): + case _i27.ConsentLog(): return 'ConsentLog'; - case _i27.DataSubjectRequest(): + case _i28.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i28.EnrollmentToken(): + case _i29.EnrollmentToken(): return 'EnrollmentToken'; - case _i29.AlertStatus(): + case _i30.AlertStatus(): return 'AlertStatus'; - case _i30.ArrivalMethod(): + case _i31.ArrivalMethod(): return 'ArrivalMethod'; - case _i31.ConsentPurpose(): + case _i32.ConsentPurpose(): return 'ConsentPurpose'; - case _i32.DataSubjectRequestStatus(): + case _i33.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i33.DataSubjectRequestType(): + case _i34.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i34.RiskLevel(): + case _i35.RiskLevel(): return 'RiskLevel'; - case _i35.SyncStatus(): + case _i36.SyncStatus(): return 'SyncStatus'; - case _i36.UserRole(): + case _i37.UserRole(): return 'UserRole'; - case _i37.AlertDispatchUnavailableException(): + case _i38.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i38.AlertPermissionException(): + case _i39.AlertPermissionException(): return 'AlertPermissionException'; - case _i39.AlertValidationException(): + case _i40.AlertValidationException(): return 'AlertValidationException'; - case _i40.AuthenticationFailedException(): + case _i41.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i41.DataRightsException(): + case _i42.DataRightsException(): return 'DataRightsException'; - case _i42.EndpointDisabledException(): + case _i43.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i43.EnrollmentException(): + case _i44.EnrollmentException(): return 'EnrollmentException'; - case _i44.NoticeDeliveryException(): + case _i45.MfaEnrollmentRequiredException(): + return 'MfaEnrollmentRequiredException'; + case _i46.MfaRequiredException(): + return 'MfaRequiredException'; + case _i47.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i45.OtpRequestException(): + case _i48.OtpRequestException(): return 'OtpRequestException'; - case _i46.MicroArea(): + case _i49.MicroArea(): return 'MicroArea'; - case _i47.OtpChallenge(): + case _i50.OtpChallenge(): return 'OtpChallenge'; - case _i48.Patient(): + case _i51.Patient(): return 'Patient'; - case _i49.PushToken(): + case _i52.PushToken(): return 'PushToken'; - case _i50.TriageAnswer(): + case _i53.TriageAnswer(): return 'TriageAnswer'; - case _i51.TriageSession(): + case _i54.TriageSession(): return 'TriageSession'; - case _i52.Ubs(): + case _i55.Ubs(): return 'Ubs'; - case _i53.User(): + case _i56.User(): return 'User'; - case _i54.UserCredential(): + case _i57.UserCredential(): return 'UserCredential'; - case _i55.Visit(): + case _i58.Visit(): return 'Visit'; } return null; @@ -815,110 +853,119 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'TermsChangeNotice') { return deserialize<_i20.TermsChangeNotice>(data['data']); } + if (dataClassName == 'TotpEnrollmentStart') { + return deserialize<_i21.TotpEnrollmentStart>(data['data']); + } if (dataClassName == 'TriageResult') { - return deserialize<_i21.TriageResult>(data['data']); + return deserialize<_i22.TriageResult>(data['data']); } if (dataClassName == 'UbsContact') { - return deserialize<_i22.UbsContact>(data['data']); + return deserialize<_i23.UbsContact>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i23.VisitSyncEntry>(data['data']); + return deserialize<_i24.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i24.VisitSyncResult>(data['data']); + return deserialize<_i25.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i25.AuditLog>(data['data']); + return deserialize<_i26.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i26.ConsentLog>(data['data']); + return deserialize<_i27.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i27.DataSubjectRequest>(data['data']); + return deserialize<_i28.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i28.EnrollmentToken>(data['data']); + return deserialize<_i29.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i29.AlertStatus>(data['data']); + return deserialize<_i30.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i30.ArrivalMethod>(data['data']); + return deserialize<_i31.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i31.ConsentPurpose>(data['data']); + return deserialize<_i32.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i32.DataSubjectRequestStatus>(data['data']); + return deserialize<_i33.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i33.DataSubjectRequestType>(data['data']); + return deserialize<_i34.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i34.RiskLevel>(data['data']); + return deserialize<_i35.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i35.SyncStatus>(data['data']); + return deserialize<_i36.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i36.UserRole>(data['data']); + return deserialize<_i37.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i37.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i38.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i38.AlertPermissionException>(data['data']); + return deserialize<_i39.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i39.AlertValidationException>(data['data']); + return deserialize<_i40.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i40.AuthenticationFailedException>(data['data']); + return deserialize<_i41.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i41.DataRightsException>(data['data']); + return deserialize<_i42.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i42.EndpointDisabledException>(data['data']); + return deserialize<_i43.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i43.EnrollmentException>(data['data']); + return deserialize<_i44.EnrollmentException>(data['data']); + } + if (dataClassName == 'MfaEnrollmentRequiredException') { + return deserialize<_i45.MfaEnrollmentRequiredException>(data['data']); + } + if (dataClassName == 'MfaRequiredException') { + return deserialize<_i46.MfaRequiredException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i44.NoticeDeliveryException>(data['data']); + return deserialize<_i47.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i45.OtpRequestException>(data['data']); + return deserialize<_i48.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i46.MicroArea>(data['data']); + return deserialize<_i49.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i47.OtpChallenge>(data['data']); + return deserialize<_i50.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i48.Patient>(data['data']); + return deserialize<_i51.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i49.PushToken>(data['data']); + return deserialize<_i52.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i50.TriageAnswer>(data['data']); + return deserialize<_i53.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i51.TriageSession>(data['data']); + return deserialize<_i54.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i52.Ubs>(data['data']); + return deserialize<_i55.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i53.User>(data['data']); + return deserialize<_i56.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i54.UserCredential>(data['data']); + return deserialize<_i57.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i55.Visit>(data['data']); + return deserialize<_i58.Visit>(data['data']); } return super.deserializeByClassName(data); } diff --git a/backend/sinalacs_client/lib/src/protocol/user_credential.dart b/backend/sinalacs_client/lib/src/protocol/user_credential.dart index eb68a328..d82eb920 100644 --- a/backend/sinalacs_client/lib/src/protocol/user_credential.dart +++ b/backend/sinalacs_client/lib/src/protocol/user_credential.dart @@ -34,6 +34,10 @@ abstract class UserCredential implements _i1.SerializableModel { this.lockedUntil, required this.createdAt, required this.updatedAt, + this.totpSecretEncrypted, + this.totpKeyVersion, + this.totpEnabledAt, + this.totpLastStep, }); factory UserCredential({ @@ -48,6 +52,10 @@ abstract class UserCredential implements _i1.SerializableModel { DateTime? lockedUntil, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) = _UserCredentialImpl; factory UserCredential.fromJson(Map jsonSerialization) { @@ -73,6 +81,14 @@ abstract class UserCredential implements _i1.SerializableModel { updatedAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['updatedAt'], ), + totpSecretEncrypted: jsonSerialization['totpSecretEncrypted'] as String?, + totpKeyVersion: jsonSerialization['totpKeyVersion'] as int?, + totpEnabledAt: jsonSerialization['totpEnabledAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['totpEnabledAt'], + ), + totpLastStep: jsonSerialization['totpLastStep'] as int?, ); } @@ -107,6 +123,18 @@ abstract class UserCredential implements _i1.SerializableModel { DateTime updatedAt; + /// MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + /// (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + String? totpSecretEncrypted; + + int? totpKeyVersion; + + /// `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + DateTime? totpEnabledAt; + + /// Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + int? totpLastStep; + /// Returns a shallow copy of this [UserCredential] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -122,6 +150,10 @@ abstract class UserCredential implements _i1.SerializableModel { DateTime? lockedUntil, DateTime? createdAt, DateTime? updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }); @override Map toJson() { @@ -138,6 +170,11 @@ abstract class UserCredential implements _i1.SerializableModel { if (lockedUntil != null) 'lockedUntil': lockedUntil?.toJson(), 'createdAt': createdAt.toJson(), 'updatedAt': updatedAt.toJson(), + if (totpSecretEncrypted != null) + 'totpSecretEncrypted': totpSecretEncrypted, + if (totpKeyVersion != null) 'totpKeyVersion': totpKeyVersion, + if (totpEnabledAt != null) 'totpEnabledAt': totpEnabledAt?.toJson(), + if (totpLastStep != null) 'totpLastStep': totpLastStep, }; } @@ -162,6 +199,10 @@ class _UserCredentialImpl extends UserCredential { DateTime? lockedUntil, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) : super._( id: id, userId: userId, @@ -174,6 +215,10 @@ class _UserCredentialImpl extends UserCredential { lockedUntil: lockedUntil, createdAt: createdAt, updatedAt: updatedAt, + totpSecretEncrypted: totpSecretEncrypted, + totpKeyVersion: totpKeyVersion, + totpEnabledAt: totpEnabledAt, + totpLastStep: totpLastStep, ); /// Returns a shallow copy of this [UserCredential] @@ -192,6 +237,10 @@ class _UserCredentialImpl extends UserCredential { Object? lockedUntil = _Undefined, DateTime? createdAt, DateTime? updatedAt, + Object? totpSecretEncrypted = _Undefined, + Object? totpKeyVersion = _Undefined, + Object? totpEnabledAt = _Undefined, + Object? totpLastStep = _Undefined, }) { return UserCredential( id: id is _i1.UuidValue? ? id : this.id, @@ -205,6 +254,16 @@ class _UserCredentialImpl extends UserCredential { lockedUntil: lockedUntil is DateTime? ? lockedUntil : this.lockedUntil, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + totpSecretEncrypted: totpSecretEncrypted is String? + ? totpSecretEncrypted + : this.totpSecretEncrypted, + totpKeyVersion: totpKeyVersion is int? + ? totpKeyVersion + : this.totpKeyVersion, + totpEnabledAt: totpEnabledAt is DateTime? + ? totpEnabledAt + : this.totpEnabledAt, + totpLastStep: totpLastStep is int? ? totpLastStep : this.totpLastStep, ); } } diff --git a/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart b/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart index 70b6c862..00551d1a 100644 --- a/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart +++ b/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart @@ -1,8 +1,32 @@ +import 'dart:math'; +import 'dart:typed_data'; + import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; +/// Estado da MFA de um ACS, como está gravado. +class TotpEnrollment { + const TotpEnrollment({required this.sealed, required this.enabled, required this.lastStep}); + + final SealedSecret sealed; + + /// `false` = a ativação começou e não foi confirmada: a MFA ainda NÃO vale. + final bool enabled; + final int? lastStep; +} + +/// Escrita do estado da MFA. Interface à parte de [AcsCredentialStore] de +/// propósito: quem só lê credencial (e os fakes que já existem) não muda. +abstract interface class TotpStore { + Future saveSecret(String acsId, SealedSecret secret, DateTime at); + Future enable(String acsId, int step, DateTime at); + Future registerStep(String acsId, int step); +} + /// O que o login precisa saber sobre uma credencial, sem `application/` /// conhecer o ORM. class AcsCredentialRecord { @@ -13,6 +37,7 @@ class AcsCredentialRecord { required this.digest, required this.failedAttempts, required this.lockedUntil, + this.totp, }); final String acsId; @@ -25,6 +50,9 @@ class AcsCredentialRecord { final PasswordDigest digest; final int failedAttempts; final DateTime? lockedUntil; + + /// Estado da MFA. `null` = nenhum segredo gravado (sem MFA). + final TotpEnrollment? totp; } /// Acesso à credencial do ACS e ao estado de bloqueio. @@ -88,12 +116,29 @@ class InstitutionalAuthService { required this.store, required this.hasher, required this.audit, - }); + this.totpStore, + this.vault, + this.requireMfa = false, + Random? random, + }) : _random = random ?? Random.secure(); final AcsCredentialStore store; final PasswordHasher hasher; final AuditTrail audit; + /// Escrita do estado da MFA. Obrigatório quando algum ACS tem MFA (ou para + /// ativá-la); a falta dele com MFA ativa é erro de montagem ([StateError]). + final TotpStore? totpStore; + + /// Cofre do segredo TOTP. Mesma regra de [totpStore]. + final TotpSecretVault? vault; + + /// `REQUIRE_ACS_MFA`: ACS sem MFA ativa não entra; precisa ativá-la antes. + final bool requireMfa; + + /// Sorteia o segredo TOTP. `Random.secure()` fora dos testes. + final Random _random; + static const maxFailedAttempts = 5; static const lockDuration = Duration(minutes: 15); @@ -103,15 +148,124 @@ class InstitutionalAuthService { static const _invalidCredentials = 'Matrícula ou senha inválidos.'; + static const _invalidCode = 'Código de verificação inválido.'; + /// Só chega a quem provou conhecer a senha (a ordem do `login` garante isso). - static const _lockMessage = 'Acesso temporariamente bloqueado por tentativas ' + static const _lockMessage = + 'Acesso temporariamente bloqueado por tentativas ' 'inválidas. Tente novamente em alguns minutos.'; Future login({ required String matricula, required String password, String? deviceId, + String? totpCode, DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword( + matricula: matricula, + password: password, + at: at, + ); + + // Só depois da senha: quem não a conhece recebe a mensagem única e nunca + // fica sabendo se a conta tem MFA. + final totp = record.totp; + if (totp != null && totp.enabled) { + final codigo = totpCode?.trim() ?? ''; + if (codigo.isEmpty) { + // A senha conferiu; falta o código. Não é falha: não conta tentativa. + throw MfaRequiredException(message: 'Informe o código do aplicativo autenticador.'); + } + final segredo = await _vault().open(totp.sealed); + final passo = Totp.verify(segredo, codigo, at, lastStep: totp.lastStep); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp'); + throw AuthenticationFailedException(message: _invalidCode); + } + await _totpStore().registerStep(record.acsId, passo); + } else if (requireMfa) { + await _recordAudit(record.acsId, 'denied_mfa_not_enrolled'); + throw MfaEnrollmentRequiredException( + message: 'Ative a verificação em duas etapas antes de entrar.', + ); + } + + await store.registerSuccessfulLogin(record.acsId, at); + await _recordAudit(record.acsId, 'granted'); + + return AuthenticatedUser( + id: record.acsId, + role: UserRole.acs, + // `_authenticatePassword` só devolve linha com território. + microAreaId: record.microAreaId!, + deviceId: deviceId ?? deviceIdAbsent, + ); + } + + /// Começa a ativação da MFA. Exige matrícula **e senha** (o ACS ainda não tem + /// token) e conta tentativa errada como o login. Só grava o segredo como + /// "pendente": a MFA vale depois de [confirmTotpEnrollment]. + Future beginTotpEnrollment({ + required String matricula, + required String password, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + if (record.totp?.enabled ?? false) { + throw AuthenticationFailedException( + message: 'A verificação em duas etapas já está ativa. Peça a redefinição à coordenação.', + ); + } + final bytes = Uint8List.fromList(List.generate(20, (_) => _random.nextInt(256))); + await _totpStore().saveSecret(record.acsId, await _vault().seal(bytes), at); + final base32 = Totp.base32(bytes); + return TotpEnrollmentStart( + secretBase32: base32, + otpauthUri: Totp.otpauthUri(secretBase32: base32, account: matricula.trim()), + ); + } + + /// Confirma a ativação com um código válido do segredo pendente. + Future confirmTotpEnrollment({ + required String matricula, + required String password, + required String code, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + final totp = record.totp; + if (totp == null || totp.enabled) { + throw AuthenticationFailedException(message: 'Não há ativação pendente para esta matrícula.'); + } + final passo = Totp.verify(await _vault().open(totp.sealed), code, at); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_enrollment'); + throw AuthenticationFailedException(message: _invalidCode); + } + await _totpStore().enable(record.acsId, passo, at); + await _recordAudit(record.acsId, 'mfa_enabled'); + } + + TotpSecretVault _vault() => vault ?? (throw StateError('MFA ativa sem cofre configurado')); + + TotpStore _totpStore() => totpStore ?? (throw StateError('MFA ativa sem cofre configurado')); + + /// Tudo o que o login decide antes de abrir a sessão: entrada em branco, + /// matrícula inexistente, senha errada (com a contagem e o bloqueio), + /// bloqueio ativo, acesso inativo e ausência de território. Devolve a linha + /// só quando a senha confere e nada disso recusa; nos outros casos lança + /// exatamente o que o `login` sempre lançou. A ativação da MFA reaproveita + /// este caminho: provar a senha ali custa o mesmo que no login. + Future _authenticatePassword({ + required String matricula, + required String password, + required DateTime at, }) async { final enrollmentId = matricula.trim(); if (enrollmentId.isEmpty || password.isEmpty) { @@ -120,7 +274,6 @@ class InstitutionalAuthService { ); } - final at = (now ?? DateTime.now()).toUtc(); final record = await store.findByEnrollmentId(enrollmentId); if (record == null) { @@ -189,14 +342,7 @@ class InstitutionalAuthService { // O reinício viaja como decisão (`restartCounter`) porque quem aplica a // contagem é o store, numa única instrução: somar aqui, sobre o valor // lido acima, perderia tentativas concorrentes. - final lockExpirou = lockedUntil != null && !lockedUntil.isAfter(at); - await store.registerFailedAttempt( - record.acsId, - restartCounter: lockExpirou, - maxFailedAttempts: maxFailedAttempts, - lockUntil: at.add(lockDuration), - at: at, - ); + await _registrarFalha(record, at); await _recordAudit(record.acsId, 'denied_credentials'); throw AuthenticationFailedException(message: _invalidCredentials); } @@ -221,25 +367,32 @@ class InstitutionalAuthService { ); } - await store.registerSuccessfulLogin(record.acsId, at); - await _recordAudit(record.acsId, 'granted'); + return record; + } - return AuthenticatedUser( - id: record.acsId, - role: UserRole.acs, - microAreaId: microAreaId, - deviceId: deviceId ?? deviceIdAbsent, + /// Conta uma tentativa falha (senha errada ou código TOTP errado). + /// Bloqueio vencido recomeça o contador — a mesma regra do ramo da senha + /// errada, num lugar só. + Future _registrarFalha(AcsCredentialRecord record, DateTime at) { + final lockedUntil = record.lockedUntil; + final lockExpirou = lockedUntil != null && !lockedUntil.isAfter(at); + return store.registerFailedAttempt( + record.acsId, + restartCounter: lockExpirou, + maxFailedAttempts: maxFailedAttempts, + lockUntil: at.add(lockDuration), + at: at, ); } /// Best-effort, como toda auditoria deste repositório: uma trilha fora do ar /// não pode impedir um ACS de entrar. Future _recordAudit(String userId, String result) => audit.recordSafely( - AuditEvent( - userId: userId, - actionType: 'login', - resourceType: 'session', - result: result, - ), - ); + AuditEvent( + userId: userId, + actionType: 'login', + resourceType: 'session', + result: result, + ), + ); } diff --git a/backend/sinalacs_server/lib/src/application/auth/totp.dart b/backend/sinalacs_server/lib/src/application/auth/totp.dart new file mode 100644 index 00000000..24e3fb98 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/totp.dart @@ -0,0 +1,89 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// TOTP (RFC 6238) com SHA-1, 6 dígitos e passo de 30 s — o padrão que todo +/// aplicativo autenticador entende. Função pura: sem relógio, sem I/O. +class Totp { + Totp._(); + + static const period = 30; + static const digits = 6; + static const _alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + static int stepOf(DateTime at) => at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ period; + + static String code(Uint8List secret, DateTime at) => _hotp(secret, stepOf(at)); + + /// Passo (`stepOf`) em que [code] confere, dentro de ±[window] passos de [at], + /// ou `null`. Com [lastStep], só aceita passo **maior** que ele: é o que + /// impede reaproveitar um código já usado dentro da janela (replay). + static int? verify( + Uint8List secret, + String code, + DateTime at, { + int window = 1, + int? lastStep, + }) { + final limpo = code.replaceAll(RegExp(r'\s'), ''); + if (limpo.length != digits || !RegExp(r'^\d+$').hasMatch(limpo)) return null; + final atual = stepOf(at); + // Todos os passos da janela são calculados e comparados, sem sair no + // primeiro que confere, e cada comparação percorre os seis dígitos: o tempo + // de resposta não diz em que passo, nem em que dígito, o código divergiu. + int? aceito; + for (var passo = atual - window; passo <= atual + window; passo++) { + final confere = _iguaisEmTempoConstante(_hotp(secret, passo), limpo); + if (confere && aceito == null && (lastStep == null || passo > lastStep)) { + aceito = passo; + } + } + return aceito; + } + + /// Compara sem saída antecipada no primeiro caractere diferente. + static bool _iguaisEmTempoConstante(String a, String b) { + if (a.length != b.length) return false; + var diferenca = 0; + for (var i = 0; i < a.length; i++) { + diferenca |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diferenca == 0; + } + + static String _hotp(Uint8List secret, int counter) { + final mensagem = ByteData(8)..setUint64(0, counter); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(digits, '0'); + } + + /// Base32 (RFC 4648, sem preenchimento): o formato do segredo que o aplicativo + /// autenticador pede quando a pessoa não lê o QR. + static String base32(Uint8List bytes) { + var bits = 0; + var valor = 0; + final saida = StringBuffer(); + for (final b in bytes) { + valor = (valor << 8) | b; + bits += 8; + while (bits >= 5) { + saida.write(_alfabeto[(valor >> (bits - 5)) & 31]); + bits -= 5; + } + valor &= (1 << bits) - 1; + } + if (bits > 0) saida.write(_alfabeto[(valor << (5 - bits)) & 31]); + return saida.toString(); + } + + static String otpauthUri({ + required String secretBase32, + required String account, + String issuer = 'SinalACS', + }) => + 'otpauth://totp/${Uri.encodeComponent(issuer)}:${Uri.encodeComponent(account)}' + '?secret=$secretBase32&issuer=${Uri.encodeComponent(issuer)}' + '&algorithm=SHA1&digits=$digits&period=$period'; +} diff --git a/backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart b/backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart new file mode 100644 index 00000000..94967ac9 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart @@ -0,0 +1,16 @@ +import 'dart:typed_data'; + +/// Segredo TOTP já cifrado, com a versão da chave que o cifrou. +class SealedSecret { + const SealedSecret({required this.ciphertextBase64, required this.keyVersion}); + + final String ciphertextBase64; + final int keyVersion; +} + +/// Cifra e decifra o segredo TOTP. A camada de aplicação não conhece a cifra: +/// quem decide o algoritmo é `infrastructure/` (`HealthCipherTotpVault`). +abstract interface class TotpSecretVault { + Future seal(Uint8List secret); + Future open(SealedSecret sealed); +} diff --git a/backend/sinalacs_server/lib/src/config/app_config.dart b/backend/sinalacs_server/lib/src/config/app_config.dart index 2d14da82..752909e9 100644 --- a/backend/sinalacs_server/lib/src/config/app_config.dart +++ b/backend/sinalacs_server/lib/src/config/app_config.dart @@ -19,6 +19,7 @@ class AppConfig { required this.mqttCaCertificatePath, required this.appEnv, required this.enableDevLogin, + this.requireAcsMfa = false, this.gorushUrl, }); @@ -87,6 +88,12 @@ class AppConfig { final String appEnv; final bool enableDevLogin; + /// `REQUIRE_ACS_MFA`: ACS sem MFA (TOTP) ativa não entra pelo login + /// institucional; precisa ativá-la antes. Padrão **ligado fora de + /// `development`** ([resolveRequireAcsMfa]). O default do construtor é + /// `false` só para as configurações montadas à mão nos testes. + final bool requireAcsMfa; + bool get isProduction => appEnv == 'production'; /// Segredo usado quando `JWT_SECRET` não é informado em desenvolvimento. @@ -199,10 +206,20 @@ class AppConfig { mqttCaCertificatePath: environment['MQTT_CA_CERT_PATH'], appEnv: appEnv, enableDevLogin: environment['ENABLE_DEV_LOGIN'] == 'true', + requireAcsMfa: resolveRequireAcsMfa( + value: environment['REQUIRE_ACS_MFA'], + appEnv: appEnv, + ), gorushUrl: _resolveGorushUrl(environment['GORUSH_URL']), ); } + /// Com a variável presente, só a string exata `true` liga (mesma regra de + /// `ENABLE_DEV_LOGIN`). Ausente, vale `true` em todo ambiente que não seja + /// `development`: esquecer a variável em produção não pode desligar a MFA. + static bool resolveRequireAcsMfa({required String? value, required String appEnv}) => + value != null ? value == 'true' : appEnv != 'development'; + static String? _resolveGorushUrl(String? value) { var url = value?.trim() ?? ''; if (url.isEmpty) return null; diff --git a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart index b127969f..0d2f5604 100644 --- a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart @@ -68,11 +68,17 @@ class AuthEndpoint extends Endpoint { /// `AuthenticationFailedException`, com a mensagem que o serviço escolheu: /// mensagem idêntica para matrícula inexistente e senha errada (ver /// `InstitutionalAuthService`). + /// + /// MFA: com a verificação em duas etapas ativa, a senha certa sem + /// [totpCode] recebe `MfaRequiredException` (o app pede o código e repete a + /// chamada); com `REQUIRE_ACS_MFA` ligado e a MFA não ativada, recebe + /// `MfaEnrollmentRequiredException` (o app chama `beginTotpEnrollment`). Future loginInstitutional( Session session, { required String matricula, required String password, String? deviceId, + String? totpCode, }) async { final runtime = AlertRuntime.instance; @@ -80,6 +86,7 @@ class AuthEndpoint extends Endpoint { matricula: matricula, password: password, deviceId: deviceId, + totpCode: totpCode, ); return DevelopmentLoginResult( @@ -88,6 +95,27 @@ class AuthEndpoint extends Endpoint { ); } + /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. + Future beginTotpEnrollment( + Session session, { + required String matricula, + required String password, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .beginTotpEnrollment(matricula: matricula, password: password); + + /// Confirma a ativação com o primeiro código do autenticador. + Future confirmTotpEnrollment( + Session session, { + required String matricula, + required String password, + required String code, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .confirmTotpEnrollment(matricula: matricula, password: password, code: code); + /// Pedido do código de acesso (RF01). Público por definição: quem chama /// ainda não tem sessão. A resposta é sempre a mesma — não revela se o CPF /// está cadastrado (ver `PasswordlessAuthService.requestOtp`). diff --git a/backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart b/backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart new file mode 100644 index 00000000..ced900b8 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart @@ -0,0 +1,94 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +/// `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +abstract class TotpEnrollmentStart + implements _i1.SerializableModel, _i1.ProtocolSerialization { + TotpEnrollmentStart._({ + required this.secretBase32, + required this.otpauthUri, + }); + + factory TotpEnrollmentStart({ + required String secretBase32, + required String otpauthUri, + }) = _TotpEnrollmentStartImpl; + + factory TotpEnrollmentStart.fromJson(Map jsonSerialization) { + return TotpEnrollmentStart( + secretBase32: jsonSerialization['secretBase32'] as String, + otpauthUri: jsonSerialization['otpauthUri'] as String, + ); + } + + String secretBase32; + + String otpauthUri; + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }); + @override + Map toJson() { + return { + '__className__': 'TotpEnrollmentStart', + 'secretBase32': secretBase32, + 'otpauthUri': otpauthUri, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'TotpEnrollmentStart', + 'secretBase32': secretBase32, + 'otpauthUri': otpauthUri, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _TotpEnrollmentStartImpl extends TotpEnrollmentStart { + _TotpEnrollmentStartImpl({ + required String secretBase32, + required String otpauthUri, + }) : super._( + secretBase32: secretBase32, + otpauthUri: otpauthUri, + ); + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }) { + return TotpEnrollmentStart( + secretBase32: secretBase32 ?? this.secretBase32, + otpauthUri: otpauthUri ?? this.otpauthUri, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 3680d58c..fcf0e20c 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -219,6 +219,11 @@ class Endpoints extends _i1.EndpointDispatch { type: _i1.getType(), nullable: true, ), + 'totpCode': _i1.ParameterDescription( + name: 'totpCode', + type: _i1.getType(), + nullable: true, + ), }, call: ( @@ -230,6 +235,63 @@ class Endpoints extends _i1.EndpointDispatch { matricula: params['matricula'], password: params['password'], deviceId: params['deviceId'], + totpCode: params['totpCode'], + ), + ), + 'beginTotpEnrollment': _i1.MethodConnector( + name: 'beginTotpEnrollment', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['auth'] as _i3.AuthEndpoint).beginTotpEnrollment( + session, + matricula: params['matricula'], + password: params['password'], + ), + ), + 'confirmTotpEnrollment': _i1.MethodConnector( + name: 'confirmTotpEnrollment', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + 'code': _i1.ParameterDescription( + name: 'code', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['auth'] as _i3.AuthEndpoint).confirmTotpEnrollment( + session, + matricula: params['matricula'], + password: params['password'], + code: params['code'], ), ), 'requestOtp': _i1.MethodConnector( diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart new file mode 100644 index 00000000..662c4d9d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart @@ -0,0 +1,76 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +/// ativar (auth.beginTotpEnrollment) antes de entrar. +abstract class MfaEnrollmentRequiredException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + MfaEnrollmentRequiredException._({required this.message}); + + factory MfaEnrollmentRequiredException({required String message}) = + _MfaEnrollmentRequiredExceptionImpl; + + factory MfaEnrollmentRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaEnrollmentRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaEnrollmentRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaEnrollmentRequiredException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'MfaEnrollmentRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaEnrollmentRequiredException(message: $message)'; + } +} + +class _MfaEnrollmentRequiredExceptionImpl + extends MfaEnrollmentRequiredException { + _MfaEnrollmentRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaEnrollmentRequiredException copyWith({String? message}) { + return MfaEnrollmentRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart new file mode 100644 index 00000000..1b5f3772 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart @@ -0,0 +1,75 @@ +/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */ +/* To generate run: "serverpod generate" */ + +// ignore_for_file: implementation_imports +// ignore_for_file: library_private_types_in_public_api +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: public_member_api_docs +// ignore_for_file: type_literal_in_constant_pattern +// ignore_for_file: use_super_parameters +// ignore_for_file: invalid_use_of_internal_member + +// ignore_for_file: no_leading_underscores_for_library_prefixes + +import 'package:serverpod/serverpod.dart' as _i1; + +/// O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +/// Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +abstract class MfaRequiredException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + MfaRequiredException._({required this.message}); + + factory MfaRequiredException({required String message}) = + _MfaRequiredExceptionImpl; + + factory MfaRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaRequiredException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'MfaRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaRequiredException(message: $message)'; + } +} + +class _MfaRequiredExceptionImpl extends MfaRequiredException { + _MfaRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaRequiredException copyWith({String? message}) { + return MfaRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index fac4b785..69349e1f 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -32,47 +32,50 @@ import 'api/patient_risk_event.dart' as _i18; import 'api/red_alert_result.dart' as _i19; import 'api/service_health.dart' as _i20; import 'api/terms_change_notice.dart' as _i21; -import 'api/triage_result.dart' as _i22; -import 'api/ubs_contact.dart' as _i23; -import 'api/visit_sync_entry.dart' as _i24; -import 'api/visit_sync_result.dart' as _i25; -import 'audit_log.dart' as _i26; -import 'consent_log.dart' as _i27; -import 'data_subject_request.dart' as _i28; -import 'enrollment_token.dart' as _i29; -import 'enums/alert_status.dart' as _i30; -import 'enums/arrival_method.dart' as _i31; -import 'enums/consent_purpose.dart' as _i32; -import 'enums/data_subject_request_status.dart' as _i33; -import 'enums/data_subject_request_type.dart' as _i34; -import 'enums/risk_level.dart' as _i35; -import 'enums/sync_status.dart' as _i36; -import 'enums/user_role.dart' as _i37; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i38; -import 'exceptions/alert_permission_exception.dart' as _i39; -import 'exceptions/alert_validation_exception.dart' as _i40; -import 'exceptions/authentication_failed_exception.dart' as _i41; -import 'exceptions/data_rights_exception.dart' as _i42; -import 'exceptions/endpoint_disabled_exception.dart' as _i43; -import 'exceptions/enrollment_exception.dart' as _i44; -import 'exceptions/notice_delivery_exception.dart' as _i45; -import 'exceptions/otp_request_exception.dart' as _i46; -import 'micro_area.dart' as _i47; -import 'otp_challenge.dart' as _i48; -import 'patient.dart' as _i49; -import 'push_token.dart' as _i50; -import 'triage_answer.dart' as _i51; -import 'triage_session.dart' as _i52; -import 'ubs.dart' as _i53; -import 'user.dart' as _i54; -import 'user_credential.dart' as _i55; -import 'visit.dart' as _i56; +import 'api/totp_enrollment_start.dart' as _i22; +import 'api/triage_result.dart' as _i23; +import 'api/ubs_contact.dart' as _i24; +import 'api/visit_sync_entry.dart' as _i25; +import 'api/visit_sync_result.dart' as _i26; +import 'audit_log.dart' as _i27; +import 'consent_log.dart' as _i28; +import 'data_subject_request.dart' as _i29; +import 'enrollment_token.dart' as _i30; +import 'enums/alert_status.dart' as _i31; +import 'enums/arrival_method.dart' as _i32; +import 'enums/consent_purpose.dart' as _i33; +import 'enums/data_subject_request_status.dart' as _i34; +import 'enums/data_subject_request_type.dart' as _i35; +import 'enums/risk_level.dart' as _i36; +import 'enums/sync_status.dart' as _i37; +import 'enums/user_role.dart' as _i38; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i39; +import 'exceptions/alert_permission_exception.dart' as _i40; +import 'exceptions/alert_validation_exception.dart' as _i41; +import 'exceptions/authentication_failed_exception.dart' as _i42; +import 'exceptions/data_rights_exception.dart' as _i43; +import 'exceptions/endpoint_disabled_exception.dart' as _i44; +import 'exceptions/enrollment_exception.dart' as _i45; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i46; +import 'exceptions/mfa_required_exception.dart' as _i47; +import 'exceptions/notice_delivery_exception.dart' as _i48; +import 'exceptions/otp_request_exception.dart' as _i49; +import 'micro_area.dart' as _i50; +import 'otp_challenge.dart' as _i51; +import 'patient.dart' as _i52; +import 'push_token.dart' as _i53; +import 'triage_answer.dart' as _i54; +import 'triage_session.dart' as _i55; +import 'ubs.dart' as _i56; +import 'user.dart' as _i57; +import 'user_credential.dart' as _i58; +import 'visit.dart' as _i59; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i57; + as _i60; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i58; + as _i61; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i59; + as _i62; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -92,6 +95,7 @@ export 'api/patient_risk_event.dart'; export 'api/red_alert_result.dart'; export 'api/service_health.dart'; export 'api/terms_change_notice.dart'; +export 'api/totp_enrollment_start.dart'; export 'api/triage_result.dart'; export 'api/ubs_contact.dart'; export 'api/visit_sync_entry.dart'; @@ -115,6 +119,8 @@ export 'exceptions/authentication_failed_exception.dart'; export 'exceptions/data_rights_exception.dart'; export 'exceptions/endpoint_disabled_exception.dart'; export 'exceptions/enrollment_exception.dart'; +export 'exceptions/mfa_enrollment_required_exception.dart'; +export 'exceptions/mfa_required_exception.dart'; export 'exceptions/notice_delivery_exception.dart'; export 'exceptions/otp_request_exception.dart'; export 'micro_area.dart'; @@ -1631,6 +1637,30 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: false, dartType: 'DateTime', ), + _i2.ColumnDefinition( + name: 'totpSecretEncrypted', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'totpKeyVersion', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + _i2.ColumnDefinition( + name: 'totpEnabledAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'totpLastStep', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), ], foreignKeys: [ _i2.ForeignKeyDefinition( @@ -2020,110 +2050,119 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i21.TermsChangeNotice) { return _i21.TermsChangeNotice.fromJson(data) as T; } - if (t == _i22.TriageResult) { - return _i22.TriageResult.fromJson(data) as T; + if (t == _i22.TotpEnrollmentStart) { + return _i22.TotpEnrollmentStart.fromJson(data) as T; + } + if (t == _i23.TriageResult) { + return _i23.TriageResult.fromJson(data) as T; + } + if (t == _i24.UbsContact) { + return _i24.UbsContact.fromJson(data) as T; } - if (t == _i23.UbsContact) { - return _i23.UbsContact.fromJson(data) as T; + if (t == _i25.VisitSyncEntry) { + return _i25.VisitSyncEntry.fromJson(data) as T; } - if (t == _i24.VisitSyncEntry) { - return _i24.VisitSyncEntry.fromJson(data) as T; + if (t == _i26.VisitSyncResult) { + return _i26.VisitSyncResult.fromJson(data) as T; } - if (t == _i25.VisitSyncResult) { - return _i25.VisitSyncResult.fromJson(data) as T; + if (t == _i27.AuditLog) { + return _i27.AuditLog.fromJson(data) as T; } - if (t == _i26.AuditLog) { - return _i26.AuditLog.fromJson(data) as T; + if (t == _i28.ConsentLog) { + return _i28.ConsentLog.fromJson(data) as T; } - if (t == _i27.ConsentLog) { - return _i27.ConsentLog.fromJson(data) as T; + if (t == _i29.DataSubjectRequest) { + return _i29.DataSubjectRequest.fromJson(data) as T; } - if (t == _i28.DataSubjectRequest) { - return _i28.DataSubjectRequest.fromJson(data) as T; + if (t == _i30.EnrollmentToken) { + return _i30.EnrollmentToken.fromJson(data) as T; } - if (t == _i29.EnrollmentToken) { - return _i29.EnrollmentToken.fromJson(data) as T; + if (t == _i31.AlertStatus) { + return _i31.AlertStatus.fromJson(data) as T; } - if (t == _i30.AlertStatus) { - return _i30.AlertStatus.fromJson(data) as T; + if (t == _i32.ArrivalMethod) { + return _i32.ArrivalMethod.fromJson(data) as T; } - if (t == _i31.ArrivalMethod) { - return _i31.ArrivalMethod.fromJson(data) as T; + if (t == _i33.ConsentPurpose) { + return _i33.ConsentPurpose.fromJson(data) as T; } - if (t == _i32.ConsentPurpose) { - return _i32.ConsentPurpose.fromJson(data) as T; + if (t == _i34.DataSubjectRequestStatus) { + return _i34.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i33.DataSubjectRequestStatus) { - return _i33.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i35.DataSubjectRequestType) { + return _i35.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i34.DataSubjectRequestType) { - return _i34.DataSubjectRequestType.fromJson(data) as T; + if (t == _i36.RiskLevel) { + return _i36.RiskLevel.fromJson(data) as T; } - if (t == _i35.RiskLevel) { - return _i35.RiskLevel.fromJson(data) as T; + if (t == _i37.SyncStatus) { + return _i37.SyncStatus.fromJson(data) as T; } - if (t == _i36.SyncStatus) { - return _i36.SyncStatus.fromJson(data) as T; + if (t == _i38.UserRole) { + return _i38.UserRole.fromJson(data) as T; } - if (t == _i37.UserRole) { - return _i37.UserRole.fromJson(data) as T; + if (t == _i39.AlertDispatchUnavailableException) { + return _i39.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i38.AlertDispatchUnavailableException) { - return _i38.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i40.AlertPermissionException) { + return _i40.AlertPermissionException.fromJson(data) as T; } - if (t == _i39.AlertPermissionException) { - return _i39.AlertPermissionException.fromJson(data) as T; + if (t == _i41.AlertValidationException) { + return _i41.AlertValidationException.fromJson(data) as T; } - if (t == _i40.AlertValidationException) { - return _i40.AlertValidationException.fromJson(data) as T; + if (t == _i42.AuthenticationFailedException) { + return _i42.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i41.AuthenticationFailedException) { - return _i41.AuthenticationFailedException.fromJson(data) as T; + if (t == _i43.DataRightsException) { + return _i43.DataRightsException.fromJson(data) as T; } - if (t == _i42.DataRightsException) { - return _i42.DataRightsException.fromJson(data) as T; + if (t == _i44.EndpointDisabledException) { + return _i44.EndpointDisabledException.fromJson(data) as T; } - if (t == _i43.EndpointDisabledException) { - return _i43.EndpointDisabledException.fromJson(data) as T; + if (t == _i45.EnrollmentException) { + return _i45.EnrollmentException.fromJson(data) as T; } - if (t == _i44.EnrollmentException) { - return _i44.EnrollmentException.fromJson(data) as T; + if (t == _i46.MfaEnrollmentRequiredException) { + return _i46.MfaEnrollmentRequiredException.fromJson(data) as T; } - if (t == _i45.NoticeDeliveryException) { - return _i45.NoticeDeliveryException.fromJson(data) as T; + if (t == _i47.MfaRequiredException) { + return _i47.MfaRequiredException.fromJson(data) as T; } - if (t == _i46.OtpRequestException) { - return _i46.OtpRequestException.fromJson(data) as T; + if (t == _i48.NoticeDeliveryException) { + return _i48.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i47.MicroArea) { - return _i47.MicroArea.fromJson(data) as T; + if (t == _i49.OtpRequestException) { + return _i49.OtpRequestException.fromJson(data) as T; } - if (t == _i48.OtpChallenge) { - return _i48.OtpChallenge.fromJson(data) as T; + if (t == _i50.MicroArea) { + return _i50.MicroArea.fromJson(data) as T; } - if (t == _i49.Patient) { - return _i49.Patient.fromJson(data) as T; + if (t == _i51.OtpChallenge) { + return _i51.OtpChallenge.fromJson(data) as T; } - if (t == _i50.PushToken) { - return _i50.PushToken.fromJson(data) as T; + if (t == _i52.Patient) { + return _i52.Patient.fromJson(data) as T; } - if (t == _i51.TriageAnswer) { - return _i51.TriageAnswer.fromJson(data) as T; + if (t == _i53.PushToken) { + return _i53.PushToken.fromJson(data) as T; } - if (t == _i52.TriageSession) { - return _i52.TriageSession.fromJson(data) as T; + if (t == _i54.TriageAnswer) { + return _i54.TriageAnswer.fromJson(data) as T; } - if (t == _i53.Ubs) { - return _i53.Ubs.fromJson(data) as T; + if (t == _i55.TriageSession) { + return _i55.TriageSession.fromJson(data) as T; } - if (t == _i54.User) { - return _i54.User.fromJson(data) as T; + if (t == _i56.Ubs) { + return _i56.Ubs.fromJson(data) as T; } - if (t == _i55.UserCredential) { - return _i55.UserCredential.fromJson(data) as T; + if (t == _i57.User) { + return _i57.User.fromJson(data) as T; } - if (t == _i56.Visit) { - return _i56.Visit.fromJson(data) as T; + if (t == _i58.UserCredential) { + return _i58.UserCredential.fromJson(data) as T; + } + if (t == _i59.Visit) { + return _i59.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -2191,134 +2230,148 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i1.getType<_i21.TermsChangeNotice?>()) { return (data != null ? _i21.TermsChangeNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.TriageResult?>()) { - return (data != null ? _i22.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i22.TotpEnrollmentStart?>()) { + return (data != null ? _i22.TotpEnrollmentStart.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i23.TriageResult?>()) { + return (data != null ? _i23.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.UbsContact?>()) { - return (data != null ? _i23.UbsContact.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.UbsContact?>()) { + return (data != null ? _i24.UbsContact.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.VisitSyncEntry?>()) { - return (data != null ? _i24.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.VisitSyncEntry?>()) { + return (data != null ? _i25.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.VisitSyncResult?>()) { - return (data != null ? _i25.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.VisitSyncResult?>()) { + return (data != null ? _i26.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.AuditLog?>()) { - return (data != null ? _i26.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.AuditLog?>()) { + return (data != null ? _i27.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.ConsentLog?>()) { - return (data != null ? _i27.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.ConsentLog?>()) { + return (data != null ? _i28.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.DataSubjectRequest?>()) { - return (data != null ? _i28.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i29.DataSubjectRequest?>()) { + return (data != null ? _i29.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.EnrollmentToken?>()) { - return (data != null ? _i29.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.EnrollmentToken?>()) { + return (data != null ? _i30.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.AlertStatus?>()) { - return (data != null ? _i30.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.AlertStatus?>()) { + return (data != null ? _i31.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.ArrivalMethod?>()) { - return (data != null ? _i31.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.ArrivalMethod?>()) { + return (data != null ? _i32.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.ConsentPurpose?>()) { - return (data != null ? _i32.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i33.ConsentPurpose?>()) { + return (data != null ? _i33.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i34.DataSubjectRequestStatus?>()) { return (data != null - ? _i33.DataSubjectRequestStatus.fromJson(data) + ? _i34.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.DataSubjectRequestType?>()) { - return (data != null ? _i34.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i35.DataSubjectRequestType?>()) { + return (data != null ? _i35.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.RiskLevel?>()) { - return (data != null ? _i35.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.RiskLevel?>()) { + return (data != null ? _i36.RiskLevel.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i37.SyncStatus?>()) { + return (data != null ? _i37.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.SyncStatus?>()) { - return (data != null ? _i36.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i38.UserRole?>()) { + return (data != null ? _i38.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.UserRole?>()) { - return (data != null ? _i37.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i39.AlertDispatchUnavailableException?>()) { + return (data != null + ? _i39.AlertDispatchUnavailableException.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i38.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i40.AlertPermissionException?>()) { return (data != null - ? _i38.AlertDispatchUnavailableException.fromJson(data) + ? _i40.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.AlertPermissionException?>()) { + if (t == _i1.getType<_i41.AlertValidationException?>()) { return (data != null - ? _i39.AlertPermissionException.fromJson(data) + ? _i41.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.AlertValidationException?>()) { + if (t == _i1.getType<_i42.AuthenticationFailedException?>()) { return (data != null - ? _i40.AlertValidationException.fromJson(data) + ? _i42.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i43.DataRightsException?>()) { + return (data != null ? _i43.DataRightsException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i44.EndpointDisabledException?>()) { return (data != null - ? _i41.AuthenticationFailedException.fromJson(data) + ? _i44.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.DataRightsException?>()) { - return (data != null ? _i42.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i45.EnrollmentException?>()) { + return (data != null ? _i45.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.EndpointDisabledException?>()) { + if (t == _i1.getType<_i46.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i43.EndpointDisabledException.fromJson(data) + ? _i46.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.EnrollmentException?>()) { - return (data != null ? _i44.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i47.MfaRequiredException?>()) { + return (data != null ? _i47.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.NoticeDeliveryException?>()) { - return (data != null ? _i45.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i48.NoticeDeliveryException?>()) { + return (data != null ? _i48.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.OtpRequestException?>()) { - return (data != null ? _i46.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i49.OtpRequestException?>()) { + return (data != null ? _i49.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.MicroArea?>()) { - return (data != null ? _i47.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.MicroArea?>()) { + return (data != null ? _i50.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.OtpChallenge?>()) { - return (data != null ? _i48.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.OtpChallenge?>()) { + return (data != null ? _i51.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.Patient?>()) { - return (data != null ? _i49.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.Patient?>()) { + return (data != null ? _i52.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.PushToken?>()) { - return (data != null ? _i50.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.PushToken?>()) { + return (data != null ? _i53.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.TriageAnswer?>()) { - return (data != null ? _i51.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.TriageAnswer?>()) { + return (data != null ? _i54.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.TriageSession?>()) { - return (data != null ? _i52.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i55.TriageSession?>()) { + return (data != null ? _i55.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.Ubs?>()) { - return (data != null ? _i53.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i56.Ubs?>()) { + return (data != null ? _i56.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.User?>()) { - return (data != null ? _i54.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i57.User?>()) { + return (data != null ? _i57.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i55.UserCredential?>()) { - return (data != null ? _i55.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i58.UserCredential?>()) { + return (data != null ? _i58.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i56.Visit?>()) { - return (data != null ? _i56.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i59.Visit?>()) { + return (data != null ? _i59.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -2347,24 +2400,24 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i57.MicroAreaPatient>) { + if (t == List<_i60.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i57.MicroAreaPatient>(e)) + .map((e) => deserialize<_i60.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i58.VisitSyncResult>) { + if (t == List<_i61.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i58.VisitSyncResult>(e)) + .map((e) => deserialize<_i61.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i59.VisitSyncEntry>) { + if (t == List<_i62.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i59.VisitSyncEntry>(e)) + .map((e) => deserialize<_i62.VisitSyncEntry>(e)) .toList() as T; } @@ -2395,42 +2448,45 @@ class Protocol extends _i1.SerializationManagerServer { _i19.RedAlertResult => 'RedAlertResult', _i20.ServiceHealth => 'ServiceHealth', _i21.TermsChangeNotice => 'TermsChangeNotice', - _i22.TriageResult => 'TriageResult', - _i23.UbsContact => 'UbsContact', - _i24.VisitSyncEntry => 'VisitSyncEntry', - _i25.VisitSyncResult => 'VisitSyncResult', - _i26.AuditLog => 'AuditLog', - _i27.ConsentLog => 'ConsentLog', - _i28.DataSubjectRequest => 'DataSubjectRequest', - _i29.EnrollmentToken => 'EnrollmentToken', - _i30.AlertStatus => 'AlertStatus', - _i31.ArrivalMethod => 'ArrivalMethod', - _i32.ConsentPurpose => 'ConsentPurpose', - _i33.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i34.DataSubjectRequestType => 'DataSubjectRequestType', - _i35.RiskLevel => 'RiskLevel', - _i36.SyncStatus => 'SyncStatus', - _i37.UserRole => 'UserRole', - _i38.AlertDispatchUnavailableException => + _i22.TotpEnrollmentStart => 'TotpEnrollmentStart', + _i23.TriageResult => 'TriageResult', + _i24.UbsContact => 'UbsContact', + _i25.VisitSyncEntry => 'VisitSyncEntry', + _i26.VisitSyncResult => 'VisitSyncResult', + _i27.AuditLog => 'AuditLog', + _i28.ConsentLog => 'ConsentLog', + _i29.DataSubjectRequest => 'DataSubjectRequest', + _i30.EnrollmentToken => 'EnrollmentToken', + _i31.AlertStatus => 'AlertStatus', + _i32.ArrivalMethod => 'ArrivalMethod', + _i33.ConsentPurpose => 'ConsentPurpose', + _i34.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i35.DataSubjectRequestType => 'DataSubjectRequestType', + _i36.RiskLevel => 'RiskLevel', + _i37.SyncStatus => 'SyncStatus', + _i38.UserRole => 'UserRole', + _i39.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i39.AlertPermissionException => 'AlertPermissionException', - _i40.AlertValidationException => 'AlertValidationException', - _i41.AuthenticationFailedException => 'AuthenticationFailedException', - _i42.DataRightsException => 'DataRightsException', - _i43.EndpointDisabledException => 'EndpointDisabledException', - _i44.EnrollmentException => 'EnrollmentException', - _i45.NoticeDeliveryException => 'NoticeDeliveryException', - _i46.OtpRequestException => 'OtpRequestException', - _i47.MicroArea => 'MicroArea', - _i48.OtpChallenge => 'OtpChallenge', - _i49.Patient => 'Patient', - _i50.PushToken => 'PushToken', - _i51.TriageAnswer => 'TriageAnswer', - _i52.TriageSession => 'TriageSession', - _i53.Ubs => 'Ubs', - _i54.User => 'User', - _i55.UserCredential => 'UserCredential', - _i56.Visit => 'Visit', + _i40.AlertPermissionException => 'AlertPermissionException', + _i41.AlertValidationException => 'AlertValidationException', + _i42.AuthenticationFailedException => 'AuthenticationFailedException', + _i43.DataRightsException => 'DataRightsException', + _i44.EndpointDisabledException => 'EndpointDisabledException', + _i45.EnrollmentException => 'EnrollmentException', + _i46.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i47.MfaRequiredException => 'MfaRequiredException', + _i48.NoticeDeliveryException => 'NoticeDeliveryException', + _i49.OtpRequestException => 'OtpRequestException', + _i50.MicroArea => 'MicroArea', + _i51.OtpChallenge => 'OtpChallenge', + _i52.Patient => 'Patient', + _i53.PushToken => 'PushToken', + _i54.TriageAnswer => 'TriageAnswer', + _i55.TriageSession => 'TriageSession', + _i56.Ubs => 'Ubs', + _i57.User => 'User', + _i58.UserCredential => 'UserCredential', + _i59.Visit => 'Visit', _ => null, }; } @@ -2483,75 +2539,81 @@ class Protocol extends _i1.SerializationManagerServer { return 'ServiceHealth'; case _i21.TermsChangeNotice(): return 'TermsChangeNotice'; - case _i22.TriageResult(): + case _i22.TotpEnrollmentStart(): + return 'TotpEnrollmentStart'; + case _i23.TriageResult(): return 'TriageResult'; - case _i23.UbsContact(): + case _i24.UbsContact(): return 'UbsContact'; - case _i24.VisitSyncEntry(): + case _i25.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i25.VisitSyncResult(): + case _i26.VisitSyncResult(): return 'VisitSyncResult'; - case _i26.AuditLog(): + case _i27.AuditLog(): return 'AuditLog'; - case _i27.ConsentLog(): + case _i28.ConsentLog(): return 'ConsentLog'; - case _i28.DataSubjectRequest(): + case _i29.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i29.EnrollmentToken(): + case _i30.EnrollmentToken(): return 'EnrollmentToken'; - case _i30.AlertStatus(): + case _i31.AlertStatus(): return 'AlertStatus'; - case _i31.ArrivalMethod(): + case _i32.ArrivalMethod(): return 'ArrivalMethod'; - case _i32.ConsentPurpose(): + case _i33.ConsentPurpose(): return 'ConsentPurpose'; - case _i33.DataSubjectRequestStatus(): + case _i34.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i34.DataSubjectRequestType(): + case _i35.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i35.RiskLevel(): + case _i36.RiskLevel(): return 'RiskLevel'; - case _i36.SyncStatus(): + case _i37.SyncStatus(): return 'SyncStatus'; - case _i37.UserRole(): + case _i38.UserRole(): return 'UserRole'; - case _i38.AlertDispatchUnavailableException(): + case _i39.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i39.AlertPermissionException(): + case _i40.AlertPermissionException(): return 'AlertPermissionException'; - case _i40.AlertValidationException(): + case _i41.AlertValidationException(): return 'AlertValidationException'; - case _i41.AuthenticationFailedException(): + case _i42.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i42.DataRightsException(): + case _i43.DataRightsException(): return 'DataRightsException'; - case _i43.EndpointDisabledException(): + case _i44.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i44.EnrollmentException(): + case _i45.EnrollmentException(): return 'EnrollmentException'; - case _i45.NoticeDeliveryException(): + case _i46.MfaEnrollmentRequiredException(): + return 'MfaEnrollmentRequiredException'; + case _i47.MfaRequiredException(): + return 'MfaRequiredException'; + case _i48.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i46.OtpRequestException(): + case _i49.OtpRequestException(): return 'OtpRequestException'; - case _i47.MicroArea(): + case _i50.MicroArea(): return 'MicroArea'; - case _i48.OtpChallenge(): + case _i51.OtpChallenge(): return 'OtpChallenge'; - case _i49.Patient(): + case _i52.Patient(): return 'Patient'; - case _i50.PushToken(): + case _i53.PushToken(): return 'PushToken'; - case _i51.TriageAnswer(): + case _i54.TriageAnswer(): return 'TriageAnswer'; - case _i52.TriageSession(): + case _i55.TriageSession(): return 'TriageSession'; - case _i53.Ubs(): + case _i56.Ubs(): return 'Ubs'; - case _i54.User(): + case _i57.User(): return 'User'; - case _i55.UserCredential(): + case _i58.UserCredential(): return 'UserCredential'; - case _i56.Visit(): + case _i59.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2624,110 +2686,119 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'TermsChangeNotice') { return deserialize<_i21.TermsChangeNotice>(data['data']); } + if (dataClassName == 'TotpEnrollmentStart') { + return deserialize<_i22.TotpEnrollmentStart>(data['data']); + } if (dataClassName == 'TriageResult') { - return deserialize<_i22.TriageResult>(data['data']); + return deserialize<_i23.TriageResult>(data['data']); } if (dataClassName == 'UbsContact') { - return deserialize<_i23.UbsContact>(data['data']); + return deserialize<_i24.UbsContact>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i24.VisitSyncEntry>(data['data']); + return deserialize<_i25.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i25.VisitSyncResult>(data['data']); + return deserialize<_i26.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i26.AuditLog>(data['data']); + return deserialize<_i27.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i27.ConsentLog>(data['data']); + return deserialize<_i28.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i28.DataSubjectRequest>(data['data']); + return deserialize<_i29.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i29.EnrollmentToken>(data['data']); + return deserialize<_i30.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i30.AlertStatus>(data['data']); + return deserialize<_i31.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i31.ArrivalMethod>(data['data']); + return deserialize<_i32.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i32.ConsentPurpose>(data['data']); + return deserialize<_i33.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i33.DataSubjectRequestStatus>(data['data']); + return deserialize<_i34.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i34.DataSubjectRequestType>(data['data']); + return deserialize<_i35.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i35.RiskLevel>(data['data']); + return deserialize<_i36.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i36.SyncStatus>(data['data']); + return deserialize<_i37.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i37.UserRole>(data['data']); + return deserialize<_i38.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i38.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i39.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i39.AlertPermissionException>(data['data']); + return deserialize<_i40.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i40.AlertValidationException>(data['data']); + return deserialize<_i41.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i41.AuthenticationFailedException>(data['data']); + return deserialize<_i42.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i42.DataRightsException>(data['data']); + return deserialize<_i43.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i43.EndpointDisabledException>(data['data']); + return deserialize<_i44.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i44.EnrollmentException>(data['data']); + return deserialize<_i45.EnrollmentException>(data['data']); + } + if (dataClassName == 'MfaEnrollmentRequiredException') { + return deserialize<_i46.MfaEnrollmentRequiredException>(data['data']); + } + if (dataClassName == 'MfaRequiredException') { + return deserialize<_i47.MfaRequiredException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i45.NoticeDeliveryException>(data['data']); + return deserialize<_i48.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i46.OtpRequestException>(data['data']); + return deserialize<_i49.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i47.MicroArea>(data['data']); + return deserialize<_i50.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i48.OtpChallenge>(data['data']); + return deserialize<_i51.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i49.Patient>(data['data']); + return deserialize<_i52.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i50.PushToken>(data['data']); + return deserialize<_i53.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i51.TriageAnswer>(data['data']); + return deserialize<_i54.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i52.TriageSession>(data['data']); + return deserialize<_i55.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i53.Ubs>(data['data']); + return deserialize<_i56.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i54.User>(data['data']); + return deserialize<_i57.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i55.UserCredential>(data['data']); + return deserialize<_i58.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i56.Visit>(data['data']); + return deserialize<_i59.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2755,32 +2826,32 @@ class Protocol extends _i1.SerializationManagerServer { return _i6.AlertIdempotencyKey.t; case _i7.AlertOutboxEntry: return _i7.AlertOutboxEntry.t; - case _i26.AuditLog: - return _i26.AuditLog.t; - case _i27.ConsentLog: - return _i27.ConsentLog.t; - case _i28.DataSubjectRequest: - return _i28.DataSubjectRequest.t; - case _i29.EnrollmentToken: - return _i29.EnrollmentToken.t; - case _i47.MicroArea: - return _i47.MicroArea.t; - case _i48.OtpChallenge: - return _i48.OtpChallenge.t; - case _i49.Patient: - return _i49.Patient.t; - case _i50.PushToken: - return _i50.PushToken.t; - case _i52.TriageSession: - return _i52.TriageSession.t; - case _i53.Ubs: - return _i53.Ubs.t; - case _i54.User: - return _i54.User.t; - case _i55.UserCredential: - return _i55.UserCredential.t; - case _i56.Visit: - return _i56.Visit.t; + case _i27.AuditLog: + return _i27.AuditLog.t; + case _i28.ConsentLog: + return _i28.ConsentLog.t; + case _i29.DataSubjectRequest: + return _i29.DataSubjectRequest.t; + case _i30.EnrollmentToken: + return _i30.EnrollmentToken.t; + case _i50.MicroArea: + return _i50.MicroArea.t; + case _i51.OtpChallenge: + return _i51.OtpChallenge.t; + case _i52.Patient: + return _i52.Patient.t; + case _i53.PushToken: + return _i53.PushToken.t; + case _i55.TriageSession: + return _i55.TriageSession.t; + case _i56.Ubs: + return _i56.Ubs.t; + case _i57.User: + return _i57.User.t; + case _i58.UserCredential: + return _i58.UserCredential.t; + case _i59.Visit: + return _i59.Visit.t; } return null; } diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 185dbc8c..936f9bcd 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -5,6 +5,8 @@ alerts: auth: - developmentLogin: - loginInstitutional: + - beginTotpEnrollment: + - confirmTotpEnrollment: - requestOtp: - verifyOtp: devices: diff --git a/backend/sinalacs_server/lib/src/generated/user_credential.dart b/backend/sinalacs_server/lib/src/generated/user_credential.dart index 74ff6bab..a2486cc8 100644 --- a/backend/sinalacs_server/lib/src/generated/user_credential.dart +++ b/backend/sinalacs_server/lib/src/generated/user_credential.dart @@ -35,6 +35,10 @@ abstract class UserCredential this.lockedUntil, required this.createdAt, required this.updatedAt, + this.totpSecretEncrypted, + this.totpKeyVersion, + this.totpEnabledAt, + this.totpLastStep, }); factory UserCredential({ @@ -49,6 +53,10 @@ abstract class UserCredential DateTime? lockedUntil, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) = _UserCredentialImpl; factory UserCredential.fromJson(Map jsonSerialization) { @@ -74,6 +82,14 @@ abstract class UserCredential updatedAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['updatedAt'], ), + totpSecretEncrypted: jsonSerialization['totpSecretEncrypted'] as String?, + totpKeyVersion: jsonSerialization['totpKeyVersion'] as int?, + totpEnabledAt: jsonSerialization['totpEnabledAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['totpEnabledAt'], + ), + totpLastStep: jsonSerialization['totpLastStep'] as int?, ); } @@ -110,6 +126,18 @@ abstract class UserCredential DateTime updatedAt; + /// MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + /// (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + String? totpSecretEncrypted; + + int? totpKeyVersion; + + /// `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + DateTime? totpEnabledAt; + + /// Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + int? totpLastStep; + @override _i1.Table<_i1.UuidValue?> get table => t; @@ -128,6 +156,10 @@ abstract class UserCredential DateTime? lockedUntil, DateTime? createdAt, DateTime? updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }); @override Map toJson() { @@ -144,6 +176,11 @@ abstract class UserCredential if (lockedUntil != null) 'lockedUntil': lockedUntil?.toJson(), 'createdAt': createdAt.toJson(), 'updatedAt': updatedAt.toJson(), + if (totpSecretEncrypted != null) + 'totpSecretEncrypted': totpSecretEncrypted, + if (totpKeyVersion != null) 'totpKeyVersion': totpKeyVersion, + if (totpEnabledAt != null) 'totpEnabledAt': totpEnabledAt?.toJson(), + if (totpLastStep != null) 'totpLastStep': totpLastStep, }; } @@ -162,6 +199,11 @@ abstract class UserCredential if (lockedUntil != null) 'lockedUntil': lockedUntil?.toJson(), 'createdAt': createdAt.toJson(), 'updatedAt': updatedAt.toJson(), + if (totpSecretEncrypted != null) + 'totpSecretEncrypted': totpSecretEncrypted, + if (totpKeyVersion != null) 'totpKeyVersion': totpKeyVersion, + if (totpEnabledAt != null) 'totpEnabledAt': totpEnabledAt?.toJson(), + if (totpLastStep != null) 'totpLastStep': totpLastStep, }; } @@ -210,6 +252,10 @@ class _UserCredentialImpl extends UserCredential { DateTime? lockedUntil, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) : super._( id: id, userId: userId, @@ -222,6 +268,10 @@ class _UserCredentialImpl extends UserCredential { lockedUntil: lockedUntil, createdAt: createdAt, updatedAt: updatedAt, + totpSecretEncrypted: totpSecretEncrypted, + totpKeyVersion: totpKeyVersion, + totpEnabledAt: totpEnabledAt, + totpLastStep: totpLastStep, ); /// Returns a shallow copy of this [UserCredential] @@ -240,6 +290,10 @@ class _UserCredentialImpl extends UserCredential { Object? lockedUntil = _Undefined, DateTime? createdAt, DateTime? updatedAt, + Object? totpSecretEncrypted = _Undefined, + Object? totpKeyVersion = _Undefined, + Object? totpEnabledAt = _Undefined, + Object? totpLastStep = _Undefined, }) { return UserCredential( id: id is _i1.UuidValue? ? id : this.id, @@ -253,6 +307,16 @@ class _UserCredentialImpl extends UserCredential { lockedUntil: lockedUntil is DateTime? ? lockedUntil : this.lockedUntil, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + totpSecretEncrypted: totpSecretEncrypted is String? + ? totpSecretEncrypted + : this.totpSecretEncrypted, + totpKeyVersion: totpKeyVersion is int? + ? totpKeyVersion + : this.totpKeyVersion, + totpEnabledAt: totpEnabledAt is DateTime? + ? totpEnabledAt + : this.totpEnabledAt, + totpLastStep: totpLastStep is int? ? totpLastStep : this.totpLastStep, ); } } @@ -313,6 +377,28 @@ class UserCredentialUpdateTable extends _i1.UpdateTable { table.updatedAt, value, ); + + _i1.ColumnValue totpSecretEncrypted(String? value) => + _i1.ColumnValue( + table.totpSecretEncrypted, + value, + ); + + _i1.ColumnValue totpKeyVersion(int? value) => _i1.ColumnValue( + table.totpKeyVersion, + value, + ); + + _i1.ColumnValue totpEnabledAt(DateTime? value) => + _i1.ColumnValue( + table.totpEnabledAt, + value, + ); + + _i1.ColumnValue totpLastStep(int? value) => _i1.ColumnValue( + table.totpLastStep, + value, + ); } class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { @@ -359,6 +445,22 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { 'updatedAt', this, ); + totpSecretEncrypted = _i1.ColumnString( + 'totpSecretEncrypted', + this, + ); + totpKeyVersion = _i1.ColumnInt( + 'totpKeyVersion', + this, + ); + totpEnabledAt = _i1.ColumnDateTime( + 'totpEnabledAt', + this, + ); + totpLastStep = _i1.ColumnInt( + 'totpLastStep', + this, + ); } late final UserCredentialUpdateTable updateTable; @@ -389,6 +491,18 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnDateTime updatedAt; + /// MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + /// (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + late final _i1.ColumnString totpSecretEncrypted; + + late final _i1.ColumnInt totpKeyVersion; + + /// `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + late final _i1.ColumnDateTime totpEnabledAt; + + /// Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + late final _i1.ColumnInt totpLastStep; + @override List<_i1.Column> get columns => [ id, @@ -402,6 +516,10 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { lockedUntil, createdAt, updatedAt, + totpSecretEncrypted, + totpKeyVersion, + totpEnabledAt, + totpLastStep, ]; } diff --git a/backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart b/backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart new file mode 100644 index 00000000..df17a8f1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; + +/// Cofre do segredo TOTP sobre a mesma AES-256-GCM dos dados clínicos +/// (`HEALTH_DATA_ENCRYPTION_KEY`). Uma chave a menos para gerir; o custo é que +/// quem a perde, perde também as MFAs — e a redefinição é manual (decisão D6). +class HealthCipherTotpVault implements TotpSecretVault { + HealthCipherTotpVault(this._cipher); + + final HealthDataCipher _cipher; + + @override + Future seal(Uint8List secret) async { + final v = await _cipher.encrypt(base64Encode(secret)); + return SealedSecret(ciphertextBase64: v.ciphertextBase64, keyVersion: v.keyVersion); + } + + @override + Future open(SealedSecret sealed) async { + final claro = await _cipher.decrypt( + EncryptedValue(ciphertextBase64: sealed.ciphertextBase64, keyVersion: sealed.keyVersion), + ); + return base64Decode(claro); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart index 8870c262..0631c057 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart @@ -1,6 +1,7 @@ import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; /// Implementação sobre o ORM do Serverpod. @@ -31,7 +32,7 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; /// `lockUntil`). Mesmo arranjo de `OrmOnboardingStore.consumeIfValid`, que /// também resolve no `SET`/`WHERE` do Postgres o que o ORM não expressa — o /// ORM não tem `UPDATE ... SET col = col + 1`. -class OrmAcsCredentialStore implements AcsCredentialStore { +class OrmAcsCredentialStore implements AcsCredentialStore, TotpStore { OrmAcsCredentialStore({required Session Function() session}) : _session = session; final Session Function() _session; @@ -83,6 +84,74 @@ class OrmAcsCredentialStore implements AcsCredentialStore { ), failedAttempts: credential.failedAttempts, lockedUntil: credential.lockedUntil, + totp: _totpOf(credential), + ); + } + + /// Estado da MFA como está na linha. Segredo sem versão de chave é linha + /// corrompida: vira erro de servidor, não "sem MFA" — tratar como ausente + /// deixaria entrar só com a senha quem tem MFA ativa. + static TotpEnrollment? _totpOf(UserCredential credential) { + final cifrado = credential.totpSecretEncrypted; + if (cifrado == null) return null; + final versao = credential.totpKeyVersion; + if (versao == null) { + throw StateError('Segredo TOTP sem versão de chave em user_credentials.'); + } + return TotpEnrollment( + sealed: SealedSecret(ciphertextBase64: cifrado, keyVersion: versao), + enabled: credential.totpEnabledAt != null, + lastStep: credential.totpLastStep, + ); + } + + /// Grava um segredo **pendente**: zera `totpEnabledAt` e `totpLastStep`, de + /// modo que a MFA só passa a valer com `enable`. Quem impede de sobrescrever + /// uma MFA já ativa é o serviço (`beginTotpEnrollment` recusa). + @override + Future saveSecret(String acsId, SealedSecret secret, DateTime at) async { + await _setColumns( + acsId, + (t) => [ + t.totpSecretEncrypted(secret.ciphertextBase64), + t.totpKeyVersion(secret.keyVersion), + t.totpEnabledAt(null), + t.totpLastStep(null), + t.updatedAt(at), + ], + ); + } + + @override + Future enable(String acsId, int step, DateTime at) async { + await _setColumns( + acsId, + (t) => [t.totpEnabledAt(at), t.totpLastStep(step), t.updatedAt(at)], + ); + } + + /// Grava o passo aceito. O `WHERE` só deixa o passo **avançar**: duas + /// requisições cruzadas não fazem o último passo voltar para trás (o que + /// reabriria a janela de replay de um código já usado). + @override + Future registerStep(String acsId, int step) async { + await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [t.totpLastStep(step)], + where: (t) => + t.userId.equals(UuidValue.fromString(acsId)) & + (t.totpLastStep.equals(null) | (t.totpLastStep < step)), + ); + } + + Future _setColumns( + String acsId, + List Function(UserCredentialUpdateTable t) columns, + ) async { + await UserCredential.db.updateWhere( + _session(), + columnValues: columns, + where: (t) => t.userId.equals(UuidValue.fromString(acsId)), ); } diff --git a/backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml b/backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml new file mode 100644 index 00000000..450bb2e1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml @@ -0,0 +1,6 @@ +### Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +### `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +class: TotpEnrollmentStart +fields: + secretBase32: String + otpauthUri: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml new file mode 100644 index 00000000..f3264a6f --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml @@ -0,0 +1,5 @@ +### O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +### ativar (auth.beginTotpEnrollment) antes de entrar. +exception: MfaEnrollmentRequiredException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml new file mode 100644 index 00000000..e616e68f --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml @@ -0,0 +1,5 @@ +### O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +### Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +exception: MfaRequiredException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml b/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml index ea1471d2..065ad087 100644 --- a/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml @@ -26,6 +26,14 @@ fields: lockedUntil: DateTime? createdAt: DateTime updatedAt: DateTime + ### MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + ### (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + totpSecretEncrypted: String? + totpKeyVersion: int? + ### `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + totpEnabledAt: DateTime? + ### Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + totpLastStep: int? indexes: user_credentials_user_id_key: fields: userId diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 88eec0ae..40229934 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -18,6 +18,7 @@ import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; import 'package:sinalacs_server/src/application/visits/visit_sync_service.dart'; import 'package:sinalacs_server/src/config/app_config.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/hmac_cpf_hasher.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; @@ -301,12 +302,17 @@ class AlertRuntime { /// chamada e a trilha de auditoria é a da requisição, para que cada /// tentativa (granted, denied_credentials, denied_locked…) vire uma linha /// encadeada em `audit_logs`. - InstitutionalAuthService institutionalAuthServiceFor(Session session) => - InstitutionalAuthService( - store: OrmAcsCredentialStore(session: () => session), - hasher: passwordHasher, - audit: auditTrailFor(session), - ); + InstitutionalAuthService institutionalAuthServiceFor(Session session) { + final store = OrmAcsCredentialStore(session: () => session); + return InstitutionalAuthService( + store: store, + hasher: passwordHasher, + audit: auditTrailFor(session), + totpStore: store, + vault: HealthCipherTotpVault(healthDataCipher), + requireMfa: config.requireAcsMfa, + ); + } /// Serviço de login passwordless do paciente (RF01) para uma requisição. /// diff --git a/backend/sinalacs_server/migrations/20261002161303717/definition.json b/backend/sinalacs_server/migrations/20261002161303717/definition.json new file mode 100644 index 00000000..6ad48b40 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/definition.json @@ -0,0 +1,3451 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationCell", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "otp_challenges", + "dartName": "OtpChallenge", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContact", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRisk", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultDisplay", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "user_credentials", + "dartName": "UserCredential", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBefore", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfter", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:RiskLevel?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage", + "dartName": "CloudStorageEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "addedTime", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "byteData", + "columnType": 5, + "isNullable": false, + "dartType": "dart:typed_data:ByteData" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "verified", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_path_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_expiration", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "expiration" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_cloud_storage_direct_upload", + "dartName": "CloudStorageDirectUploadEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_cloud_storage_direct_upload_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "storageId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "path", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiration", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authKey", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_cloud_storage_direct_upload_storage_path", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "storageId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "path" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_future_call", + "dartName": "FutureCallEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_future_call_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serializedObject", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "identifier", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_serverId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_future_call_identifier_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "identifier" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_connection_info", + "dartName": "ServerHealthConnectionInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_connection_info_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "closing", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idle", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_connection_info_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_health_metric", + "dartName": "ServerHealthMetric", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_health_metric_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isHealthy", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "value", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "granularity", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_health_metric_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "name" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "granularity" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_log", + "dartName": "LogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "reference", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logLevel", + "columnType": 6, + "isNullable": false, + "dartType": "protocol:LogLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_message_log", + "dartName": "MessageLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_message_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageName", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_message_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_message_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_method", + "dartName": "MethodInfo", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_method_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_method_endpoint_method_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "endpoint" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "method" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_migrations", + "dartName": "DatabaseMigrationVersion", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_migrations_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_migrations_ids", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "module" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_query_log", + "dartName": "QueryLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_query_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sessionLogId", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "messageId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "query", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": false, + "dartType": "double" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numRows", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "order", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "serverpod_query_log_fk_0", + "columns": [ + "sessionLogId" + ], + "referenceTable": "serverpod_session_log", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 4 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_query_log_sessionLogId_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sessionLogId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_readwrite_test", + "dartName": "ReadWriteTestEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_readwrite_test_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "number", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_readwrite_test_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_runtime_settings", + "dartName": "RuntimeSettings", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_runtime_settings_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettings", + "columnType": 8, + "isNullable": false, + "dartType": "protocol:LogSettings" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logSettingsOverrides", + "columnType": 8, + "isNullable": false, + "dartType": "List" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logServiceCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "logMalformedCalls", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_runtime_settings_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "serverpod_session_log", + "dartName": "SessionLogEntry", + "module": "serverpod", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 6, + "isNullable": false, + "columnDefault": "nextval('serverpod_session_log_id_seq'::regclass)", + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "serverId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "time", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "module", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "endpoint", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "method", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "duration", + "columnType": 3, + "isNullable": true, + "dartType": "double?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "numQueries", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "slow", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "error", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "stackTrace", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authenticatedUserId", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isOpen", + "columnType": 1, + "isNullable": true, + "dartType": "bool?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "touched", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_serverid_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "serverId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_time_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "time" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_touched_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "touched" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "serverpod_session_log_isopen_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "isOpen" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "sinalacs", + "version": "20261002161303717" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002161303717/definition.sql b/backend/sinalacs_server/migrations/20261002161303717/definition.sql new file mode 100644 index 00000000..f23deef3 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/definition.sql @@ -0,0 +1,719 @@ +BEGIN; + +-- +-- Class Acs as table acs +-- +CREATE TABLE "acs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "ubsId" uuid NOT NULL, + "active" boolean NOT NULL, + "lastSyncAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "acs_ubs_idx" ON "acs" USING btree ("ubsId"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AlertDeliveryRecord as table alert_deliveries +-- +CREATE TABLE "alert_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "acknowledgedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_deliveries_alert_acs_key" ON "alert_deliveries" USING btree ("alertId", "acsId"); + +-- +-- Class AlertIdempotencyKey as table alert_idempotency_keys +-- +CREATE TABLE "alert_idempotency_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "key" text NOT NULL, + "alertId" uuid NOT NULL, + "locationHash" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "alert_idempotency_keys_key_key" ON "alert_idempotency_keys" USING btree ("key"); + +-- +-- Class AlertOutboxEntry as table alert_outbox +-- +CREATE TABLE "alert_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "alertId" uuid NOT NULL, + "topic" text NOT NULL, + "payload" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "publishedAt" timestamp without time zone, + "attempts" bigint NOT NULL, + "nextAttemptAt" timestamp without time zone NOT NULL, + "lastError" text +); + +-- Indexes +CREATE INDEX "alert_outbox_pending_idx" ON "alert_outbox" USING btree ("publishedAt", "nextAttemptAt"); + +-- +-- Class Alert as table alerts +-- +CREATE TABLE "alerts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid, + "microAreaId" uuid, + "triggeredAt" timestamp without time zone NOT NULL, + "receivedAt" timestamp without time zone, + "respondedAt" timestamp without time zone, + "acknowledgedAt" timestamp without time zone, + "riskLevel" text NOT NULL, + "locationHash" text NOT NULL, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditLog as table audit_logs +-- +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "actionType" text NOT NULL, + "resourceType" text NOT NULL, + "resourceId" uuid, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "result" text NOT NULL, + "sequence" bigint NOT NULL, + "previousHash" text NOT NULL, + "entryHash" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_logs_sequence_idx" ON "audit_logs" USING btree ("sequence"); + +-- +-- Class ConsentLog as table consent_logs +-- +CREATE TABLE "consent_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "purpose" text NOT NULL, + "action" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "ipHash" text NOT NULL, + "userAgent" text NOT NULL, + "signature" text NOT NULL +); + +-- +-- Class DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- Class MicroArea as table micro_areas +-- +CREATE TABLE "micro_areas" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "ubsId" uuid NOT NULL, + "geoJsonBoundary" text NOT NULL +); + +-- Indexes +CREATE INDEX "micro_areas_ubs_idx" ON "micro_areas" USING btree ("ubsId"); + +-- +-- Class OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "resultRisk" text NOT NULL, + "resultDisplay" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "deviceId" text NOT NULL +); + +-- +-- Class Ubs as table ubs +-- +CREATE TABLE "ubs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" text NOT NULL, + "address" text NOT NULL, + "city" text NOT NULL, + "state" text NOT NULL, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- Class User as table users +-- +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "cpfHash" text NOT NULL, + "name" text NOT NULL, + "birthDate" timestamp without time zone NOT NULL, + "role" text NOT NULL, + "microAreaId" uuid, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "users_cpf_hash_key" ON "users" USING btree ("cpfHash"); +CREATE INDEX "users_micro_area_idx" ON "users" USING btree ("microAreaId"); + +-- +-- Class Visit as table visits +-- +CREATE TABLE "visits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "acsId" uuid NOT NULL, + "scheduledAt" timestamp without time zone NOT NULL, + "startedAt" timestamp without time zone, + "completedAt" timestamp without time zone, + "status" text NOT NULL, + "riskLevelBefore" text NOT NULL, + "riskLevelAfter" text, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "localId" uuid NOT NULL, + "syncAt" timestamp without time zone, + "version" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "visits_local_id_key" ON "visits" USING btree ("localId"); + +-- +-- Class CloudStorageEntry as table serverpod_cloud_storage +-- +CREATE TABLE "serverpod_cloud_storage" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "addedTime" timestamp without time zone NOT NULL, + "expiration" timestamp without time zone, + "byteData" bytea NOT NULL, + "verified" boolean NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_path_idx" ON "serverpod_cloud_storage" USING btree ("storageId", "path"); +CREATE INDEX "serverpod_cloud_storage_expiration" ON "serverpod_cloud_storage" USING btree ("expiration"); + +-- +-- Class CloudStorageDirectUploadEntry as table serverpod_cloud_storage_direct_upload +-- +CREATE TABLE "serverpod_cloud_storage_direct_upload" ( + "id" bigserial PRIMARY KEY, + "storageId" text NOT NULL, + "path" text NOT NULL, + "expiration" timestamp without time zone NOT NULL, + "authKey" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_cloud_storage_direct_upload_storage_path" ON "serverpod_cloud_storage_direct_upload" USING btree ("storageId", "path"); + +-- +-- Class FutureCallEntry as table serverpod_future_call +-- +CREATE TABLE "serverpod_future_call" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "serializedObject" text, + "serverId" text NOT NULL, + "identifier" text +); + +-- Indexes +CREATE INDEX "serverpod_future_call_time_idx" ON "serverpod_future_call" USING btree ("time"); +CREATE INDEX "serverpod_future_call_serverId_idx" ON "serverpod_future_call" USING btree ("serverId"); +CREATE INDEX "serverpod_future_call_identifier_idx" ON "serverpod_future_call" USING btree ("identifier"); + +-- +-- Class ServerHealthConnectionInfo as table serverpod_health_connection_info +-- +CREATE TABLE "serverpod_health_connection_info" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "active" bigint NOT NULL, + "closing" bigint NOT NULL, + "idle" bigint NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_connection_info_timestamp_idx" ON "serverpod_health_connection_info" USING btree ("timestamp", "serverId", "granularity"); + +-- +-- Class ServerHealthMetric as table serverpod_health_metric +-- +CREATE TABLE "serverpod_health_metric" ( + "id" bigserial PRIMARY KEY, + "name" text NOT NULL, + "serverId" text NOT NULL, + "timestamp" timestamp without time zone NOT NULL, + "isHealthy" boolean NOT NULL, + "value" double precision NOT NULL, + "granularity" bigint NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_health_metric_timestamp_idx" ON "serverpod_health_metric" USING btree ("timestamp", "serverId", "name", "granularity"); + +-- +-- Class LogEntry as table serverpod_log +-- +CREATE TABLE "serverpod_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "reference" text, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "logLevel" bigint NOT NULL, + "message" text NOT NULL, + "error" text, + "stackTrace" text, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_log_sessionLogId_idx" ON "serverpod_log" USING btree ("sessionLogId"); + +-- +-- Class MessageLogEntry as table serverpod_message_log +-- +CREATE TABLE "serverpod_message_log" ( + "id" bigserial PRIMARY KEY, + "sessionLogId" bigint NOT NULL, + "serverId" text NOT NULL, + "messageId" bigint NOT NULL, + "endpoint" text NOT NULL, + "messageName" text NOT NULL, + "duration" double precision NOT NULL, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- +-- Class MethodInfo as table serverpod_method +-- +CREATE TABLE "serverpod_method" ( + "id" bigserial PRIMARY KEY, + "endpoint" text NOT NULL, + "method" text NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_method_endpoint_method_idx" ON "serverpod_method" USING btree ("endpoint", "method"); + +-- +-- Class DatabaseMigrationVersion as table serverpod_migrations +-- +CREATE TABLE "serverpod_migrations" ( + "id" bigserial PRIMARY KEY, + "module" text NOT NULL, + "version" text NOT NULL, + "timestamp" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "serverpod_migrations_ids" ON "serverpod_migrations" USING btree ("module"); + +-- +-- Class QueryLogEntry as table serverpod_query_log +-- +CREATE TABLE "serverpod_query_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "sessionLogId" bigint NOT NULL, + "messageId" bigint, + "query" text NOT NULL, + "duration" double precision NOT NULL, + "numRows" bigint, + "error" text, + "stackTrace" text, + "slow" boolean NOT NULL, + "order" bigint NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_query_log_sessionLogId_idx" ON "serverpod_query_log" USING btree ("sessionLogId"); + +-- +-- Class ReadWriteTestEntry as table serverpod_readwrite_test +-- +CREATE TABLE "serverpod_readwrite_test" ( + "id" bigserial PRIMARY KEY, + "number" bigint NOT NULL +); + +-- +-- Class RuntimeSettings as table serverpod_runtime_settings +-- +CREATE TABLE "serverpod_runtime_settings" ( + "id" bigserial PRIMARY KEY, + "logSettings" json NOT NULL, + "logSettingsOverrides" json NOT NULL, + "logServiceCalls" boolean NOT NULL, + "logMalformedCalls" boolean NOT NULL +); + +-- +-- Class SessionLogEntry as table serverpod_session_log +-- +CREATE TABLE "serverpod_session_log" ( + "id" bigserial PRIMARY KEY, + "serverId" text NOT NULL, + "time" timestamp without time zone NOT NULL, + "module" text, + "endpoint" text, + "method" text, + "duration" double precision, + "numQueries" bigint, + "slow" boolean, + "error" text, + "stackTrace" text, + "authenticatedUserId" bigint, + "userId" text, + "isOpen" boolean, + "touched" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "serverpod_session_log_serverid_idx" ON "serverpod_session_log" USING btree ("serverId"); +CREATE INDEX "serverpod_session_log_time_idx" ON "serverpod_session_log" USING btree ("time"); +CREATE INDEX "serverpod_session_log_touched_idx" ON "serverpod_session_log" USING btree ("touched"); +CREATE INDEX "serverpod_session_log_isopen_idx" ON "serverpod_session_log" USING btree ("isOpen"); + +-- +-- Foreign relations for "alert_deliveries" table +-- +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alert_deliveries" + ADD CONSTRAINT "alert_deliveries_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_idempotency_keys" table +-- +ALTER TABLE ONLY "alert_idempotency_keys" + ADD CONSTRAINT "alert_idempotency_keys_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alert_outbox" table +-- +ALTER TABLE ONLY "alert_outbox" + ADD CONSTRAINT "alert_outbox_fk_0" + FOREIGN KEY("alertId") + REFERENCES "alerts"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "alerts" table +-- +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "alerts" + ADD CONSTRAINT "alerts_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "audit_logs" table +-- +ALTER TABLE ONLY "audit_logs" + ADD CONSTRAINT "audit_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "consent_logs" table +-- +ALTER TABLE ONLY "consent_logs" + ADD CONSTRAINT "consent_logs_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "triage_sessions" table +-- +ALTER TABLE ONLY "triage_sessions" + ADD CONSTRAINT "triage_sessions_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "visits" table +-- +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "visits" + ADD CONSTRAINT "visits_fk_1" + FOREIGN KEY("acsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_log" table +-- +ALTER TABLE ONLY "serverpod_log" + ADD CONSTRAINT "serverpod_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_message_log" table +-- +ALTER TABLE ONLY "serverpod_message_log" + ADD CONSTRAINT "serverpod_message_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "serverpod_query_log" table +-- +ALTER TABLE ONLY "serverpod_query_log" + ADD CONSTRAINT "serverpod_query_log_fk_0" + FOREIGN KEY("sessionLogId") + REFERENCES "serverpod_session_log"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261002161303717', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002161303717', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/20261002161303717/definition_project.json b/backend/sinalacs_server/migrations/20261002161303717/definition_project.json new file mode 100644 index 00000000..2425dfe1 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/definition_project.json @@ -0,0 +1,2115 @@ +{ + "__className__": "serverpod.DatabaseDefinition", + "moduleName": "sinalacs", + "tables": [ + { + "__className__": "serverpod.TableDefinition", + "name": "acs", + "dartName": "Acs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "enrollmentId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSyncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_deliveries", + "dartName": "AlertDeliveryRecord", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_deliveries_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_deliveries_alert_acs_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "alertId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "acsId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_idempotency_keys", + "dartName": "AlertIdempotencyKey", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "key", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_idempotency_keys_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_idempotency_keys_key_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "key" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alert_outbox", + "dartName": "AlertOutboxEntry", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "alertId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "topic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "payload", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "nextAttemptAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastError", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alert_outbox_fk_0", + "columns": [ + "alertId" + ], + "referenceTable": "alerts", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alert_outbox_pending_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "publishedAt" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "nextAttemptAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "alerts", + "dartName": "Alert", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "triggeredAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "receivedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "respondedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acknowledgedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevel", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "locationCell", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:AlertStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "mqttTopic", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "retryCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "alerts_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "alerts_micro_area_status_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "triggeredAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_logs", + "dartName": "AuditLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "actionType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceType", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resourceId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "result", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "previousHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "consent_logs", + "dartName": "ConsentLog", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "purpose", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "action", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "timestamp", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ipHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userAgent", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "consent_logs_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "consent_logs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "micro_areas", + "dartName": "MicroArea", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "geoJsonBoundary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "micro_areas_ubs_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "ubsId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "otp_challenges", + "dartName": "OtpChallenge", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "patients", + "dartName": "Patient", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContact", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastLocationHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastTriageAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "patients_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "triage_sessions", + "dartName": "TriageSession", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultRisk", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resultDisplay", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "triage_sessions_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "triage_sessions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "ubs", + "dartName": "Ubs", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "address", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "city", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "state", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "ubs_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "user_credentials", + "dartName": "UserCredential", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "userId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "users", + "dartName": "User", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "cpfHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "name", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "birthDate", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "role", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:UserRole" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_cpf_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "users_micro_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "visits", + "dartName": "Visit", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "acsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "startedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "completedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelBefore", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:RiskLevel" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "riskLevelAfter", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:RiskLevel?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "localId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 6, + "isNullable": false, + "dartType": "int" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "visits_fk_1", + "columns": [ + "acsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "visits_local_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "localId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + ], + "installedModules": [ + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002161303717/migration.json b/backend/sinalacs_server/migrations/20261002161303717/migration.json new file mode 100644 index 00000000..82f47963 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/migration.json @@ -0,0 +1,53 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "user_credentials", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002161303717/migration.sql b/backend/sinalacs_server/migrations/20261002161303717/migration.sql new file mode 100644 index 00000000..11d60eec --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/migration.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "user_credentials" ADD COLUMN "totpSecretEncrypted" text; +ALTER TABLE "user_credentials" ADD COLUMN "totpKeyVersion" bigint; +ALTER TABLE "user_credentials" ADD COLUMN "totpEnabledAt" timestamp without time zone; +ALTER TABLE "user_credentials" ADD COLUMN "totpLastStep" bigint; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261002161303717', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002161303717', "timestamp" = now(); + +-- +-- MIGRATION VERSION FOR serverpod +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('serverpod', '20260129180959368', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260129180959368', "timestamp" = now(); + + +COMMIT; diff --git a/backend/sinalacs_server/migrations/migration_registry.txt b/backend/sinalacs_server/migrations/migration_registry.txt index 87918467..b49eed6d 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -16,3 +16,4 @@ 20260929005339393 20260929192823862 20261002153125316 +20261002161303717 diff --git a/backend/sinalacs_server/test/integration/institutional_mfa_test.dart b/backend/sinalacs_server/test/integration/institutional_mfa_test.dart new file mode 100644 index 00000000..8138a6b9 --- /dev/null +++ b/backend/sinalacs_server/test/integration/institutional_mfa_test.dart @@ -0,0 +1,249 @@ +import 'dart:typed_data'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// MFA por TOTP (RF07) contra Postgres real: prova que as quatro colunas +/// `totp*` de `user_credentials` são gravadas e relidas pelo +/// `OrmAcsCredentialStore` — o que o store falso do teste unitário não +/// alcança — e que o segredo passa pelo cofre de verdade +/// (`HealthCipherTotpVault`, AES-256-GCM) sem nunca ir em claro para a linha. +/// +/// Dados sintéticos; ids próprios, distintos das outras suítes. +const _acsId = '00000000-0000-4000-8000-000000000081'; +const _microAreaId = '00000000-0000-4000-8000-000000000082'; +const _ubsId = '00000000-0000-4000-8000-000000000083'; +const _matricula = 'ACS-MFA-001'; +const _senha = 'senha-sintetica-de-teste'; + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS MFA', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea MFA', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs-mfa', + name: 'ACS de desenvolvimento (MFA)', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: _matricula, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _acsId, + await AlertRuntimeHarness.hasher.derive(_senha), + now, + ); +} + +Future _linha(Session session) async => (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_acsId)), + ))!; + +/// Base32 (RFC 4648, sem preenchimento) → bytes: o que o aplicativo +/// autenticador faz com o segredo devolvido por `beginTotpEnrollment`. +Uint8List _deBase32(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.split('')) { + valor = (valor << 5) | alfabeto.indexOf(c); + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8List.fromList(saida); +} + +class _SemAuditoria extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +void main() { + withServerpod('Dada a MFA por TOTP do login institucional (RF07)', ( + sessionBuilder, + endpoints, + ) { + setUp(() async => _seed(sessionBuilder.build())); + + test('begin → confirm → login com código, e o replay é recusado', () async { + final session = sessionBuilder.build(); + + final inicio = await endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + ); + final segredo = _deBase32(inicio.secretBase32); + expect(segredo, hasLength(20)); + expect(Totp.base32(segredo), inicio.secretBase32); + + // Pendente: segredo cifrado na linha, MFA ainda não vale. + final pendente = await _linha(session); + expect(pendente.totpSecretEncrypted, isNotNull); + expect(pendente.totpSecretEncrypted, isNot(contains(inicio.secretBase32))); + expect(pendente.totpKeyVersion, isNotNull); + expect(pendente.totpEnabledAt, isNull); + expect(pendente.totpLastStep, isNull); + + // E pendente não bloqueia o login só com a senha. + final semMfa = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + ); + expect(semMfa.accessToken, isNotEmpty); + + final agora = DateTime.now().toUtc(); + await endpoints.auth.confirmTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + code: Totp.code(segredo, agora), + ); + final ativa = await _linha(session); + expect(ativa.totpEnabledAt, isNotNull); + final passoDaAtivacao = ativa.totpLastStep; + expect(passoDaAtivacao, isNotNull); + + // Ativa: a senha sozinha não entra mais. + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + ), + throwsA(isA()), + ); + + // O código usado na ativação não serve para entrar (o passo dele já foi + // gravado); o do passo seguinte serve — está dentro da janela de ±1. + final codigoUsado = Totp.code(segredo, agora); + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + totpCode: codigoUsado, + ), + throwsA(isA()), + ); + expect((await _linha(session)).failedAttempts, 1); + + final proximo = agora.add(const Duration(seconds: Totp.period)); + final codigo = Totp.code(segredo, proximo); + final login = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + totpCode: codigo, + ); + expect(AlertRuntimeHarness.verify(login.accessToken)?.id, _acsId); + final depois = await _linha(session); + expect(depois.totpLastStep, Totp.stepOf(proximo)); + expect(depois.failedAttempts, 0); + + // Replay do código que acabou de entrar: recusado pelo passo gravado. + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + totpCode: codigo, + ), + throwsA(isA()), + ); + + // Com a MFA ativa, uma nova ativação é recusada e não troca o segredo. + await expectLater( + endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + ), + throwsA(isA()), + ); + expect((await _linha(session)).totpSecretEncrypted, ativa.totpSecretEncrypted); + }); + + test('registerStep só deixa o passo avançar, nunca voltar', () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session); + + await store.registerStep(_acsId, 100); + expect((await _linha(session)).totpLastStep, 100); + + await store.registerStep(_acsId, 99); + expect((await _linha(session)).totpLastStep, 100); + + await store.registerStep(_acsId, 101); + expect((await _linha(session)).totpLastStep, 101); + }); + + test('com requireMfa ligado, ACS sem MFA ativa recebe MfaEnrollmentRequiredException', + () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session); + final servico = InstitutionalAuthService( + store: store, + hasher: AlertRuntimeHarness.hasher, + audit: _SemAuditoria(), + totpStore: store, + vault: HealthCipherTotpVault(AlertRuntime.instance.healthDataCipher), + requireMfa: true, + ); + + await expectLater( + servico.login(matricula: _matricula, password: _senha), + throwsA(isA()), + ); + // Não é tentativa errada: a senha conferiu. + expect((await _linha(session)).failedAttempts, 0); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart index a778ca60..90a8b27d 100644 --- a/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart +++ b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart @@ -20,31 +20,33 @@ import 'package:sinalacs_server/src/generated/api/alert_status_result.dart' as _i6; import 'package:sinalacs_server/src/generated/api/development_login_result.dart' as _i7; -import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i8; +import 'package:sinalacs_server/src/generated/api/totp_enrollment_start.dart' + as _i8; +import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i9; import 'package:sinalacs_server/src/generated/api/notice_send_result.dart' - as _i9; -import 'package:sinalacs_server/src/generated/api/enrollment_token_result.dart' as _i10; -import 'package:sinalacs_server/src/generated/api/enrollment_result.dart' +import 'package:sinalacs_server/src/generated/api/enrollment_token_result.dart' as _i11; -import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' +import 'package:sinalacs_server/src/generated/api/enrollment_result.dart' as _i12; -import 'package:sinalacs_server/src/generated/api/patient_data_overview.dart' +import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' as _i13; -import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' +import 'package:sinalacs_server/src/generated/api/patient_data_overview.dart' as _i14; -import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' +import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' as _i15; -import 'package:sinalacs_server/src/generated/api/terms_change_notice.dart' +import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' as _i16; -import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' +import 'package:sinalacs_server/src/generated/api/terms_change_notice.dart' as _i17; -import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i18; -import 'package:sinalacs_server/src/generated/api/ubs_contact.dart' as _i19; +import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' + as _i18; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i19; +import 'package:sinalacs_server/src/generated/api/ubs_contact.dart' as _i20; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i20; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i21; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i22; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/generated/endpoints.dart'; export 'package:serverpod_test/serverpod_test_public_exports.dart'; @@ -392,6 +394,7 @@ class _AuthEndpoint { required String matricula, required String password, String? deviceId, + String? totpCode, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -408,6 +411,7 @@ class _AuthEndpoint { 'matricula': matricula, 'password': password, 'deviceId': deviceId, + 'totpCode': totpCode, }), serializationManager: _serializationManager, ); @@ -424,6 +428,78 @@ class _AuthEndpoint { }); } + _i3.Future<_i8.TotpEnrollmentStart> beginTotpEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'beginTotpEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'beginTotpEnrollment', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i8.TotpEnrollmentStart>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future confirmTotpEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + required String code, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'confirmTotpEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'confirmTotpEnrollment', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + 'code': code, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + _i3.Future requestOtp( _i1.TestSessionBuilder sessionBuilder, { required String cpf, @@ -555,7 +631,7 @@ class _HealthEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i8.ServiceHealth> check( + _i3.Future<_i9.ServiceHealth> check( _i1.TestSessionBuilder sessionBuilder, ) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { @@ -577,7 +653,7 @@ class _HealthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i8.ServiceHealth>); + as _i3.Future<_i9.ServiceHealth>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -596,7 +672,7 @@ class _NoticesEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i9.NoticeSendResult> sendSegmented( + _i3.Future<_i10.NoticeSendResult> sendSegmented( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String title, @@ -627,7 +703,7 @@ class _NoticesEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i9.NoticeSendResult>); + as _i3.Future<_i10.NoticeSendResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -646,7 +722,7 @@ class _OnboardingEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i10.EnrollmentTokenResult> generateEnrollmentToken( + _i3.Future<_i11.EnrollmentTokenResult> generateEnrollmentToken( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String patientId, @@ -673,7 +749,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i10.EnrollmentTokenResult>); + as _i3.Future<_i11.EnrollmentTokenResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -681,7 +757,7 @@ class _OnboardingEndpoint { }); } - _i3.Future<_i11.EnrollmentResult> completeEnrollment( + _i3.Future<_i12.EnrollmentResult> completeEnrollment( _i1.TestSessionBuilder sessionBuilder, { required String token, required bool healthDataConsent, @@ -714,7 +790,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i11.EnrollmentResult>); + as _i3.Future<_i12.EnrollmentResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -733,7 +809,7 @@ class _PatientsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> listMicroArea( + _i3.Future> listMicroArea( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -756,7 +832,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -830,7 +906,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i13.PatientDataOverview> myData( + _i3.Future<_i14.PatientDataOverview> myData( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -853,7 +929,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i13.PatientDataOverview>); + as _i3.Future<_i14.PatientDataOverview>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -861,10 +937,10 @@ class _PatientsEndpoint { }); } - _i3.Future<_i14.PatientConsentRecord> updateConsent( + _i3.Future<_i15.PatientConsentRecord> updateConsent( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required _i15.ConsentPurpose purpose, + required _i16.ConsentPurpose purpose, required bool granted, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { @@ -890,7 +966,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i14.PatientConsentRecord>); + as _i3.Future<_i15.PatientConsentRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -898,7 +974,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i14.PatientConsentRecord> acceptTermsOfUse( + _i3.Future<_i15.PatientConsentRecord> acceptTermsOfUse( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -921,7 +997,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i14.PatientConsentRecord>); + as _i3.Future<_i15.PatientConsentRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -963,7 +1039,7 @@ class _PatientsEndpoint { _i3.Future hasGrantedConsent( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required _i15.ConsentPurpose purpose, + required _i16.ConsentPurpose purpose, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -995,7 +1071,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i16.TermsChangeNotice?> termsChangeNotice( + _i3.Future<_i17.TermsChangeNotice?> termsChangeNotice( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -1018,7 +1094,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i16.TermsChangeNotice?>); + as _i3.Future<_i17.TermsChangeNotice?>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1026,7 +1102,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i17.PatientDataSubjectRequestRecord> requestDataDeletion( + _i3.Future<_i18.PatientDataSubjectRequestRecord> requestDataDeletion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -1049,7 +1125,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i17.PatientDataSubjectRequestRecord>); + as _i3.Future<_i18.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1057,7 +1133,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i17.PatientDataSubjectRequestRecord> requestDataCorrection( + _i3.Future<_i18.PatientDataSubjectRequestRecord> requestDataCorrection( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String details, @@ -1084,7 +1160,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i17.PatientDataSubjectRequestRecord>); + as _i3.Future<_i18.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1103,7 +1179,7 @@ class _TriageEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i18.TriageResult> evaluate( + _i3.Future<_i19.TriageResult> evaluate( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required bool chestPain, @@ -1140,7 +1216,7 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i18.TriageResult>); + as _i3.Future<_i19.TriageResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1159,7 +1235,7 @@ class _UbsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i19.UbsContact> myContact( + _i3.Future<_i20.UbsContact> myContact( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -1182,7 +1258,7 @@ class _UbsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i19.UbsContact>); + as _i3.Future<_i20.UbsContact>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1201,10 +1277,10 @@ class _VisitsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i21.VisitSyncEntry> visits, + required List<_i22.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -1228,7 +1304,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1236,7 +1312,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -1263,7 +1339,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); diff --git a/backend/sinalacs_server/test/unit/app_config_test.dart b/backend/sinalacs_server/test/unit/app_config_test.dart index 432e4e69..355b25ee 100644 --- a/backend/sinalacs_server/test/unit/app_config_test.dart +++ b/backend/sinalacs_server/test/unit/app_config_test.dart @@ -408,4 +408,30 @@ void main() { expect(comGorush(null).gorushTimeout, const Duration(seconds: 5)); }); }); + + group('REQUIRE_ACS_MFA (RF07)', () { + // Fora de `development` nenhuma configuração inteira pode ser montada + // (ver o cabeçalho: `SMS_GATEWAY`), então a regra de produção é provada + // no resolvedor que o `fromMap` usa; o caso de development, pelo `fromMap`. + test('development sem a variável: desligado', () { + expect(AppConfig.fromMap(const {'APP_ENV': 'development'}).requireAcsMfa, isFalse); + expect(AppConfig.resolveRequireAcsMfa(value: null, appEnv: 'development'), isFalse); + }); + + test('production (ou qualquer ambiente fora de development) sem a variável: ligado', () { + expect(AppConfig.resolveRequireAcsMfa(value: null, appEnv: 'production'), isTrue); + expect(AppConfig.resolveRequireAcsMfa(value: null, appEnv: 'staging'), isTrue); + }); + + test('REQUIRE_ACS_MFA=false explícito desliga mesmo em produção', () { + expect(AppConfig.resolveRequireAcsMfa(value: 'false', appEnv: 'production'), isFalse); + }); + + test('REQUIRE_ACS_MFA=true explícito liga em development', () { + expect( + AppConfig.fromMap(const {'APP_ENV': 'development', 'REQUIRE_ACS_MFA': 'true'}).requireAcsMfa, + isTrue, + ); + }); + }); } diff --git a/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart b/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart index 1b7ec9b6..73634a89 100644 --- a/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart +++ b/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart @@ -42,6 +42,13 @@ const _publicMethodsByDesign = { 'login por desenho: quem pede o código ainda não tem sessão', 'AuthEndpoint.verifyOtp': 'login por desenho: é esta chamada que emite a sessão do paciente', + 'AuthEndpoint.beginTotpEnrollment': + 'ativação da MFA (RF07) acontece ANTES do primeiro token: com ' + 'REQUIRE_ACS_MFA o ACS sem MFA não recebe sessão — matrícula e senha SÃO ' + 'a credencial, e a tentativa errada conta no bloqueio como no login', + 'AuthEndpoint.confirmTotpEnrollment': + 'mesma razão de beginTotpEnrollment: matrícula + senha + código válido ' + 'do segredo pendente são a credencial; não há token a exigir ainda', }; /// A classe que o teste protege — e cuja declaração ele precisa ignorar. diff --git a/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart b/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart new file mode 100644 index 00000000..3bee5e44 --- /dev/null +++ b/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _senha = 'senha-sintetica-de-teste'; + +/// Cofre de teste: base64 reversível. Quem prova a cifra real é `health_data_cipher_test.dart`. +class _Cofre implements TotpSecretVault { + @override + Future seal(Uint8List secret) async => + SealedSecret(ciphertextBase64: base64Encode(secret), keyVersion: 1); + + @override + Future open(SealedSecret sealed) async => base64Decode(sealed.ciphertextBase64); +} + +class _Store implements AcsCredentialStore, TotpStore { + _Store(this.record); + AcsCredentialRecord record; + int enabledCalls = 0; + int? lastRegisteredStep; + + @override + Future findByEnrollmentId(String enrollmentId) async => + enrollmentId == 'ACS-001' ? record : null; + + @override + Future registerFailedAttempt(String acsId, + {required bool restartCounter, + required int maxFailedAttempts, + required DateTime lockUntil, + required DateTime at}) async { + final anterior = record.failedAttempts; + final proximo = restartCounter ? 1 : anterior + 1; + record = _copia(failedAttempts: proximo, lockedUntil: proximo >= maxFailedAttempts ? lockUntil : null); + } + + @override + Future registerSuccessfulLogin(String acsId, DateTime at) async { + record = _copia(failedAttempts: 0, lockedUntil: null); + } + + @override + Future saveCredential(String acsId, PasswordDigest digest, DateTime at) async {} + + @override + Future saveSecret(String acsId, SealedSecret s, DateTime at) async { + record = _copia(totp: TotpEnrollment(sealed: s, enabled: false, lastStep: null)); + } + + @override + Future enable(String acsId, int step, DateTime at) async { + enabledCalls++; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: true, lastStep: step)); + } + + @override + Future registerStep(String acsId, int step) async { + lastRegisteredStep = step; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: t.enabled, lastStep: step)); + } + + AcsCredentialRecord _copia({int? failedAttempts, DateTime? lockedUntil, TotpEnrollment? totp}) => + AcsCredentialRecord( + acsId: record.acsId, + microAreaId: record.microAreaId, + active: record.active, + digest: record.digest, + failedAttempts: failedAttempts ?? record.failedAttempts, + lockedUntil: failedAttempts != null ? lockedUntil : record.lockedUntil, + totp: totp ?? record.totp, + ); +} + +class _Audit extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +void main() { + final hasher = Argon2PasswordHasher(memoryKb: 512, iterations: 1, parallelism: 1); + final cofre = _Cofre(); + final segredo = Uint8List.fromList(List.generate(20, (i) => i + 1)); + final t0 = DateTime.utc(2026, 10, 2, 12); + + Future<({InstitutionalAuthService servico, _Store store})> montar({ + bool comMfa = true, + bool exigir = false, + }) async { + final digest = await hasher.derive(_senha); + final sealed = await cofre.seal(segredo); + final store = _Store(AcsCredentialRecord( + acsId: _acsId, + microAreaId: _microAreaId, + active: true, + digest: digest, + failedAttempts: 0, + lockedUntil: null, + totp: comMfa ? TotpEnrollment(sealed: sealed, enabled: true, lastStep: null) : null, + )); + final servico = InstitutionalAuthService( + store: store, + hasher: hasher, + audit: _Audit(), + totpStore: store, + vault: cofre, + requireMfa: exigir, + random: Random(7), + ); + return (servico: servico, store: store); + } + + group('login com MFA ativa', () { + test('senha certa sem código → MfaRequiredException, sem contar tentativa', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + expect(m.store.record.failedAttempts, 0); + }); + + test('senha certa + código certo → entra e grava o passo usado', () async { + final m = await montar(); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + expect(m.store.lastRegisteredStep, Totp.stepOf(t0)); + }); + + test('recusa o mesmo código usado duas vezes (replay)', () async { + final m = await montar(); + final codigo = Totp.code(segredo, t0); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0); + + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0), + throwsA(isA()), + ); + }); + + test('código do passo SEGUINTE ainda entra depois de um login (relógio adiantado)', () async { + final m = await montar(); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + final proximo = t0.add(const Duration(seconds: 30)); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, proximo), now: proximo); + expect(user.id, _acsId); + }); + + test('código errado conta tentativa e tranca no 5º erro', () async { + final m = await montar(); + for (var i = 0; i < 4; i++) { + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + } + expect(m.store.record.failedAttempts, 4); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.record.lockedUntil, isNotNull); + }); + + test('código errado não consome o passo (o certo logo depois entra)', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.lastRegisteredStep, isNull); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + }); + + test('senha errada continua dando a mensagem única, com ou sem código', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: 'errada', totpCode: Totp.code(segredo, t0), now: t0), + throwsA(isA()), + ); + }); + }); + + group('login sem MFA ativa', () { + test('com REQUIRE_ACS_MFA desligado, entra só com a senha', () async { + final m = await montar(comMfa: false); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + + test('com REQUIRE_ACS_MFA ligado, a senha certa recebe MfaEnrollmentRequiredException', () async { + final m = await montar(comMfa: false, exigir: true); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + }); + + test('enrollment começado e NÃO confirmado não vale: entra só com a senha', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(m.store.record.totp!.enabled, isFalse); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + }); + + group('ativação', () { + test('begin devolve segredo e URI; confirm com o código certo ativa', () async { + final m = await montar(comMfa: false); + final inicio = await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(inicio.otpauthUri, startsWith('otpauth://totp/SinalACS:ACS-001?secret=${inicio.secretBase32}')); + + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: 'ACS-001', + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + expect(m.store.enabledCalls, 1); + expect(m.store.record.totp!.enabled, isTrue); + }); + + test('confirm com código errado não ativa e conta tentativa', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + await expectLater( + m.servico.confirmTotpEnrollment(matricula: 'ACS-001', password: _senha, code: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.enabledCalls, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('begin com senha errada é recusado como o login (mensagem única)', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: 'errada'), + throwsA(isA()), + ); + }); + + test('begin com MFA já ativa é recusado (redefinir exige a coordenação)', () async { + final m = await montar(); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha), + throwsA(isA()), + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/totp_test.dart b/backend/sinalacs_server/test/unit/totp_test.dart new file mode 100644 index 00000000..5f290944 --- /dev/null +++ b/backend/sinalacs_server/test/unit/totp_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:test/test.dart'; + +void main() { + // Segredo da RFC 6238 (Apêndice B): ASCII "12345678901234567890". + final segredo = Uint8List.fromList(ascii.encode('12345678901234567890')); + DateTime em(int segundos) => DateTime.fromMillisecondsSinceEpoch(segundos * 1000, isUtc: true); + + group('RFC 6238 — vetores do SHA-1 (6 últimos dígitos)', () { + final vetores = { + 59: '287082', + 1111111109: '081804', + 1111111111: '050471', + 1234567890: '005924', + 2000000000: '279037', + 20000000000: '353130', + }; + vetores.forEach((t, esperado) { + test('T=$t → $esperado', () => expect(Totp.code(segredo, em(t)), esperado)); + }); + }); + + test('base32 do segredo da RFC é o valor conhecido', () { + expect(Totp.base32(segredo), 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); + }); + + test('o passo muda a cada 30 s', () { + expect(Totp.stepOf(em(29)), 0); + expect(Totp.stepOf(em(30)), 1); + }); + + group('verify', () { + test('aceita o código do passo atual e devolve o passo', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora), Totp.stepOf(agora)); + }); + + test('aceita um passo antes e um depois (relógio fora por até 30 s)', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.subtract(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) - 1); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) + 1); + }); + + test('recusa dois passos de distância', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 60))), agora), isNull); + }); + + test('recusa passo já usado (replay): só aceita passo MAIOR que lastStep', () { + final agora = em(1111111111); + final passo = Totp.stepOf(agora); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo), isNull); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo - 1), passo); + }); + + test('recusa código com tamanho errado ou não numérico, sem lançar', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, '12345', agora), isNull); + expect(Totp.verify(segredo, '1234567', agora), isNull); + expect(Totp.verify(segredo, 'abcdef', agora), isNull); + expect(Totp.verify(segredo, '', agora), isNull); + }); + + test('ignora espaços em volta do código (apps agrupam "123 456")', () { + final agora = em(1111111111); + final c = Totp.code(segredo, agora); + expect(Totp.verify(segredo, '${c.substring(0, 3)} ${c.substring(3)}', agora), Totp.stepOf(agora)); + }); + }); + + test('otpauthUri tem o formato que os autenticadores leem', () { + final uri = Totp.otpauthUri(secretBase32: 'ABC234', account: 'ACS-001'); + expect(uri, 'otpauth://totp/SinalACS:ACS-001?secret=ABC234&issuer=SinalACS&algorithm=SHA1&digits=6&period=30'); + }); +} diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 98e53f1d..48bded94 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -12,5 +12,8 @@ services: SERVERPOD_DATABASE_USER: postgres SERVERPOD_DATABASE_PASSWORD: ${TEST_DATABASE_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} ENABLE_DEV_LOGIN: "false" + # Explícito: o e2e entra com matrícula + senha sem ativar MFA. Não herdar + # o valor do .env impede que um REQUIRE_ACS_MFA=true local derrube a jornada. + REQUIRE_ACS_MFA: "false" SMS_GATEWAY: log GORUSH_URL: http://gorush:8088 diff --git a/docker-compose.yml b/docker-compose.yml index 22099347..e6535ccc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,6 +160,9 @@ services: CPF_HASH_PEPPER: ${CPF_HASH_PEPPER:-} APP_ENV: ${APP_ENV:-development} ENABLE_DEV_LOGIN: "${ENABLE_DEV_LOGIN:-true}" + # MFA (TOTP) obrigatória no login institucional do ACS. Em produção o + # padrão é `true`; este default é só da stack local. + REQUIRE_ACS_MFA: "${REQUIRE_ACS_MFA:-false}" # O RPC só é alcançável por HTTPS em :443. A publicação de 8080 foi # REMOVIDA de propósito: enquanto ela existisse, o TLS seria decorativo — # RNF04 pede TLS em todas as comunicações, e um caminho em texto claro ao diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 74505ffb..499b9553 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -21,6 +21,10 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `memoryKb` / `iterations` / `parallelism` | `bigint` | Metadado Técnico | Parâmetros do Argon2id vigentes na gravação | Gravados junto do hash para que subir o custo não invalide credencial antiga. | | | `failedAttempts` | `bigint` | Metadado de Segurança | Tentativas falhas desde o último sucesso | Base do bloqueio (achado F6). | | | `lockedUntil` | `timestamp without time zone` | Metadado de Segurança | Fim do bloqueio; `NULL` = não bloqueado | — | +| | `totpSecretEncrypted` | `text` | **Crítico** — segredo de autenticação | AES-256-GCM (`HealthCipherTotpVault`, mesma chave `HEALTH_DATA_ENCRYPTION_KEY` dos dados clínicos); `NULL` = sem MFA | Segredo TOTP da MFA do ACS (RF07). Nunca em claro: volta ao ACS uma única vez, na resposta de `auth.beginTotpEnrollment`. Perder a chave invalida também as MFAs (redefinição manual). | +| | `totpKeyVersion` | `bigint` | Metadado de Segurança | Versão da chave que cifrou o segredo | — | +| | `totpEnabledAt` | `timestamp without time zone` | Metadado de Segurança | Confirmação da ativação; `NULL` = ativação pendente (MFA ainda não vale) | — | +| | `totpLastStep` | `bigint` | Metadado de Segurança | Último passo de 30 s aceito | Barra o replay de um código já usado dentro da janela. | | | `createdAt` / `updatedAt` | `timestamp without time zone` | Metadado Técnico | Timestamps | — | | **otp_challenges** | `id` | `uuid` | Pseudonimizado | UUID da linha | — | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | — | From dc517f7b2ec614b25f8aac1fc4dcaa5d4ae1c1c0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:26:15 -0400 Subject: [PATCH 36/43] =?UTF-8?q?fix(auth):=20MFA=20recusa=20replay=20conc?= =?UTF-8?q?orrente=20do=20TOTP=20e=20ativa=C3=A7=C3=A3o=20em=20corrida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TotpStore.registerStep/enable/saveSecret devolvem se a linha mudou; o UPDATE condicional decide quem ganha, e quem perde é recusado (RFC 6238 §5.2): login com código já validado conta falha e audita denied_totp_replay. - enable só ativa o segredo pendente que o código conferiu; saveSecret não sobrescreve MFA já ativa. - Begin bem-sucedido auditado como mfa_enrollment_started; mensagem própria para store de TOTP ausente. - Integração com concorrência real: N logins (ou confirmações) simultâneos com o mesmo código abrem exatamente uma sessão (ou ativam uma vez). --- .../auth/institutional_auth_service.dart | 44 +++- .../database/orm_acs_credential_store.dart | 57 +++--- .../integration/institutional_mfa_test.dart | 192 +++++++++++++++++- .../unit/institutional_auth_mfa_test.dart | 59 +++++- 4 files changed, 313 insertions(+), 39 deletions(-) diff --git a/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart b/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart index 00551d1a..7ab580c8 100644 --- a/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart +++ b/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart @@ -22,9 +22,20 @@ class TotpEnrollment { /// Escrita do estado da MFA. Interface à parte de [AcsCredentialStore] de /// propósito: quem só lê credencial (e os fakes que já existem) não muda. abstract interface class TotpStore { - Future saveSecret(String acsId, SealedSecret secret, DateTime at); - Future enable(String acsId, int step, DateTime at); - Future registerStep(String acsId, int step); + /// Grava um segredo **pendente**. Devolve `false` sem escrever quando a MFA + /// já está ativa na linha: uma ativação concorrente que confirmou antes não + /// pode ser sobrescrita por quem só conhece a senha. + Future saveSecret(String acsId, SealedSecret secret, DateTime at); + + /// Ativa a MFA com [pending], o segredo que o código conferiu. Devolve + /// `false` sem escrever quando a linha já não está pendente com esse mesmo + /// segredo (outra confirmação ganhou, ou um novo início trocou o segredo). + Future enable(String acsId, SealedSecret pending, int step, DateTime at); + + /// Avança o último passo aceito para [step]. Devolve `true` só se a linha + /// avançou; `false` = outra requisição já gravou este passo (ou um maior): + /// o código é um replay e a sessão **não** pode ser emitida. + Future registerStep(String acsId, int step); } /// O que o login precisa saber sobre uma credencial, sem `application/` @@ -185,7 +196,14 @@ class InstitutionalAuthService { await _recordAudit(record.acsId, 'denied_totp'); throw AuthenticationFailedException(message: _invalidCode); } - await _totpStore().registerStep(record.acsId, passo); + // O `verify` acima leu `lastStep` antes; duas requisições com o mesmo + // código passam as duas por ele. Quem decide é o `UPDATE` condicional: + // só uma avança a linha, e a outra é replay (RFC 6238 §5.2). + if (!await _totpStore().registerStep(record.acsId, passo)) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_replay'); + throw AuthenticationFailedException(message: _invalidCode); + } } else if (requireMfa) { await _recordAudit(record.acsId, 'denied_mfa_not_enrolled'); throw MfaEnrollmentRequiredException( @@ -221,7 +239,14 @@ class InstitutionalAuthService { ); } final bytes = Uint8List.fromList(List.generate(20, (_) => _random.nextInt(256))); - await _totpStore().saveSecret(record.acsId, await _vault().seal(bytes), at); + if (!await _totpStore().saveSecret(record.acsId, await _vault().seal(bytes), at)) { + // Uma confirmação concorrente ativou a MFA entre a leitura e a escrita. + throw AuthenticationFailedException( + message: 'A verificação em duas etapas já está ativa. Peça a redefinição à coordenação.', + ); + } + // Sobrescreve qualquer segredo pendente anterior: fica na trilha. + await _recordAudit(record.acsId, 'mfa_enrollment_started'); final base32 = Totp.base32(bytes); return TotpEnrollmentStart( secretBase32: base32, @@ -248,13 +273,18 @@ class InstitutionalAuthService { await _recordAudit(record.acsId, 'denied_totp_enrollment'); throw AuthenticationFailedException(message: _invalidCode); } - await _totpStore().enable(record.acsId, passo, at); + if (!await _totpStore().enable(record.acsId, totp.sealed, passo, at)) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_enrollment_race'); + throw AuthenticationFailedException(message: 'Não há ativação pendente para esta matrícula.'); + } await _recordAudit(record.acsId, 'mfa_enabled'); } TotpSecretVault _vault() => vault ?? (throw StateError('MFA ativa sem cofre configurado')); - TotpStore _totpStore() => totpStore ?? (throw StateError('MFA ativa sem cofre configurado')); + TotpStore _totpStore() => + totpStore ?? (throw StateError('MFA ativa sem store de TOTP configurado')); /// Tudo o que o login decide antes de abrir a sessão: entrada em branco, /// matrícula inexistente, senha errada (com a contagem e o bloqueio), diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart index 0631c057..7c9a9708 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart @@ -106,53 +106,58 @@ class OrmAcsCredentialStore implements AcsCredentialStore, TotpStore { } /// Grava um segredo **pendente**: zera `totpEnabledAt` e `totpLastStep`, de - /// modo que a MFA só passa a valer com `enable`. Quem impede de sobrescrever - /// uma MFA já ativa é o serviço (`beginTotpEnrollment` recusa). + /// modo que a MFA só passa a valer com `enable`. O `WHERE totpEnabledAt IS + /// NULL` recusa sobrescrever uma MFA já ativa mesmo quando o serviço leu a + /// linha antes de uma confirmação concorrente gravar: sem ele, quem só tem a + /// senha poderia trocar o segredo de uma MFA recém-ativada. @override - Future saveSecret(String acsId, SealedSecret secret, DateTime at) async { - await _setColumns( - acsId, - (t) => [ + Future saveSecret(String acsId, SealedSecret secret, DateTime at) async { + final linhas = await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [ t.totpSecretEncrypted(secret.ciphertextBase64), t.totpKeyVersion(secret.keyVersion), t.totpEnabledAt(null), t.totpLastStep(null), t.updatedAt(at), ], + where: (t) => t.userId.equals(UuidValue.fromString(acsId)) & t.totpEnabledAt.equals(null), ); + return linhas.isNotEmpty; } + /// Ativa só se a linha ainda está pendente **com o mesmo segredo** que o + /// código conferiu: duas confirmações concorrentes ativam uma vez só, e um + /// novo início no meio (segredo trocado) não é ativado por um código do + /// segredo antigo. @override - Future enable(String acsId, int step, DateTime at) async { - await _setColumns( - acsId, - (t) => [t.totpEnabledAt(at), t.totpLastStep(step), t.updatedAt(at)], + Future enable(String acsId, SealedSecret pending, int step, DateTime at) async { + final linhas = await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [t.totpEnabledAt(at), t.totpLastStep(step), t.updatedAt(at)], + where: (t) => + t.userId.equals(UuidValue.fromString(acsId)) & + t.totpEnabledAt.equals(null) & + t.totpSecretEncrypted.equals(pending.ciphertextBase64), ); + return linhas.isNotEmpty; } - /// Grava o passo aceito. O `WHERE` só deixa o passo **avançar**: duas - /// requisições cruzadas não fazem o último passo voltar para trás (o que - /// reabriria a janela de replay de um código já usado). + /// Grava o passo aceito e diz se gravou. O `WHERE` só deixa o passo + /// **avançar**; sob READ COMMITTED, a segunda de duas requisições com o + /// mesmo código espera o lock da linha, reavalia o `WHERE` contra o passo + /// que a primeira gravou e não altera nada — devolve `false`, e o serviço + /// recusa a sessão (replay). @override - Future registerStep(String acsId, int step) async { - await UserCredential.db.updateWhere( + Future registerStep(String acsId, int step) async { + final linhas = await UserCredential.db.updateWhere( _session(), columnValues: (t) => [t.totpLastStep(step)], where: (t) => t.userId.equals(UuidValue.fromString(acsId)) & (t.totpLastStep.equals(null) | (t.totpLastStep < step)), ); - } - - Future _setColumns( - String acsId, - List Function(UserCredentialUpdateTable t) columns, - ) async { - await UserCredential.db.updateWhere( - _session(), - columnValues: columns, - where: (t) => t.userId.equals(UuidValue.fromString(acsId)), - ); + return linhas.isNotEmpty; } @override diff --git a/backend/sinalacs_server/test/integration/institutional_mfa_test.dart b/backend/sinalacs_server/test/integration/institutional_mfa_test.dart index 8138a6b9..ee28abe5 100644 --- a/backend/sinalacs_server/test/integration/institutional_mfa_test.dart +++ b/backend/sinalacs_server/test/integration/institutional_mfa_test.dart @@ -2,8 +2,10 @@ import 'dart:typed_data'; import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; @@ -215,16 +217,41 @@ void main() { final session = sessionBuilder.build(); final store = OrmAcsCredentialStore(session: () => session); - await store.registerStep(_acsId, 100); + expect(await store.registerStep(_acsId, 100), isTrue); expect((await _linha(session)).totpLastStep, 100); - await store.registerStep(_acsId, 99); + // Mesmo passo e passo menor: nada muda, e o store diz que não gravou. + expect(await store.registerStep(_acsId, 100), isFalse); + expect(await store.registerStep(_acsId, 99), isFalse); expect((await _linha(session)).totpLastStep, 100); - await store.registerStep(_acsId, 101); + expect(await store.registerStep(_acsId, 101), isTrue); expect((await _linha(session)).totpLastStep, 101); }); + test('saveSecret não sobrescreve MFA ativa; enable só ativa o segredo pendente que conferiu', + () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session); + const a = SealedSecret(ciphertextBase64: 'cifra-sintetica-a', keyVersion: 1); + const b = SealedSecret(ciphertextBase64: 'cifra-sintetica-b', keyVersion: 1); + final at = DateTime.now().toUtc(); + + expect(await store.saveSecret(_acsId, a, at), isTrue); + // Um novo início trocou o segredo: o código do antigo não ativa o novo. + expect(await store.saveSecret(_acsId, b, at), isTrue); + expect(await store.enable(_acsId, a, 10, at), isFalse); + expect((await _linha(session)).totpEnabledAt, isNull); + + expect(await store.enable(_acsId, b, 10, at), isTrue); + // Ativa: segunda confirmação e novo início são recusados pela linha. + expect(await store.enable(_acsId, b, 11, at), isFalse); + expect(await store.saveSecret(_acsId, a, at), isFalse); + final linha = await _linha(session); + expect(linha.totpSecretEncrypted, b.ciphertextBase64); + expect(linha.totpLastStep, 10); + }); + test('com requireMfa ligado, ACS sem MFA ativa recebe MfaEnrollmentRequiredException', () async { final session = sessionBuilder.build(); @@ -246,4 +273,163 @@ void main() { expect((await _linha(session)).failedAttempts, 0); }); }); + + // Concorrência de verdade exige o rollback DESLIGADO: com ele, todas as + // `Session` do harness dividem o mesmo `TransactionManager` e chamadas + // simultâneas são recusadas (ver o grupo "rajada" de + // `institutional_login_test.dart`). Sem rollback, cada tentativa abre a sua + // conexão. Pelo serviço com store ORM e cofre reais, e trilha de mentira: + // auditoria commitada quebraria a cadeia que outras suítes conferem. + withServerpod( + 'Dada a MFA por TOTP, sem rollback automático (concorrência)', + (sessionBuilder, endpoints) { + test('o mesmo código válido em requisições simultâneas abre UMA sessão só', () async { + await _seedCorrida(sessionBuilder.build()); + try { + final segredo = await _ativarMfaCorrida(sessionBuilder); + final codigo = Totp.code( + segredo, + DateTime.now().toUtc().add(const Duration(seconds: Totp.period)), + ); + + final resultados = await Future.wait([ + for (var i = 0; i < _concorrentes; i++) + _servicoCorrida(sessionBuilder) + .login(matricula: _corridaMatricula, password: _senha, totpCode: codigo) + .then((u) => u, onError: (Object e) => e), + ]); + + expect(resultados.whereType(), hasLength(1), + reason: 'RFC 6238 §5.2: um código validado não vale de novo'); + expect( + resultados.where((r) => r is! AuthenticatedUser), + everyElement(isA()), + ); + } finally { + await _limparCorrida(sessionBuilder.build()); + } + }); + + test('a mesma confirmação em requisições simultâneas ativa UMA vez só', () async { + await _seedCorrida(sessionBuilder.build()); + try { + final inicio = await _servicoCorrida(sessionBuilder) + .beginTotpEnrollment(matricula: _corridaMatricula, password: _senha); + final codigo = Totp.code(_deBase32(inicio.secretBase32), DateTime.now().toUtc()); + + final resultados = await Future.wait([ + for (var i = 0; i < _concorrentes; i++) + _servicoCorrida(sessionBuilder) + .confirmTotpEnrollment(matricula: _corridaMatricula, password: _senha, code: codigo) + .then((_) => 'ativou', onError: (Object e) => e), + ]); + + expect(resultados.where((r) => r == 'ativou'), hasLength(1)); + expect( + resultados.where((r) => r != 'ativou'), + everyElement(isA()), + ); + } finally { + await _limparCorrida(sessionBuilder.build()); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} + +const _concorrentes = 5; +const _corridaAcsId = '00000000-0000-4000-8000-000000000091'; +const _corridaMicroAreaId = '00000000-0000-4000-8000-000000000092'; +const _corridaUbsId = '00000000-0000-4000-8000-000000000093'; +const _corridaMatricula = 'ACS-MFA-CORRIDA-001'; + +/// Uma `Session` por chamada, como no servidor: conexões independentes. +InstitutionalAuthService _servicoCorrida(TestSessionBuilder sessionBuilder) { + final store = OrmAcsCredentialStore(session: () => sessionBuilder.build()); + return InstitutionalAuthService( + store: store, + hasher: AlertRuntimeHarness.hasher, + audit: _SemAuditoria(), + totpStore: store, + vault: HealthCipherTotpVault(AlertRuntime.instance.healthDataCipher), + ); +} + +/// Ativa a MFA (begin + confirm, em sequência) e devolve o segredo. +Future _ativarMfaCorrida(TestSessionBuilder sessionBuilder) async { + final inicio = await _servicoCorrida(sessionBuilder) + .beginTotpEnrollment(matricula: _corridaMatricula, password: _senha); + final segredo = _deBase32(inicio.secretBase32); + await _servicoCorrida(sessionBuilder).confirmTotpEnrollment( + matricula: _corridaMatricula, + password: _senha, + code: Totp.code(segredo, DateTime.now().toUtc()), + ); + return segredo; +} + +/// Idempotente: limpa primeiro, para uma execução que morreu no meio não +/// deixar chave duplicada. +Future _seedCorrida(Session session) async { + await _limparCorrida(session); + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_corridaUbsId), + name: 'UBS MFA Corrida', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_corridaMicroAreaId), + name: 'Microárea MFA Corrida', + ubsId: UuidValue.fromString(_corridaUbsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_corridaAcsId), + cpfHash: 'development-acs-mfa-corrida', + name: 'ACS de desenvolvimento (MFA corrida)', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_corridaMicroAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_corridaAcsId), + enrollmentId: _corridaMatricula, + ubsId: UuidValue.fromString(_corridaUbsId), + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _corridaAcsId, + await AlertRuntimeHarness.hasher.derive(_senha), + now, + ); +} + +Future _limparCorrida(Session session) async { + final id = UuidValue.fromString(_corridaAcsId); + await UserCredential.db.deleteWhere(session, where: (t) => t.userId.equals(id)); + await Acs.db.deleteWhere(session, where: (t) => t.id.equals(id)); + await User.db.deleteWhere(session, where: (t) => t.id.equals(id)); + await MicroArea.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_corridaMicroAreaId)), + ); + await Ubs.db.deleteWhere(session, where: (t) => t.id.equals(UuidValue.fromString(_corridaUbsId))); } diff --git a/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart b/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart index 3bee5e44..79e72862 100644 --- a/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart +++ b/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart @@ -30,6 +30,11 @@ class _Store implements AcsCredentialStore, TotpStore { AcsCredentialRecord record; int enabledCalls = 0; int? lastRegisteredStep; + int successfulLogins = 0; + + /// Simula a requisição concorrente que gravou antes: o `UPDATE` condicional + /// do store real não altera linha nenhuma e devolve `false`. + bool perdeCorrida = false; @override Future findByEnrollmentId(String enrollmentId) async => @@ -48,6 +53,7 @@ class _Store implements AcsCredentialStore, TotpStore { @override Future registerSuccessfulLogin(String acsId, DateTime at) async { + successfulLogins++; record = _copia(failedAttempts: 0, lockedUntil: null); } @@ -55,22 +61,28 @@ class _Store implements AcsCredentialStore, TotpStore { Future saveCredential(String acsId, PasswordDigest digest, DateTime at) async {} @override - Future saveSecret(String acsId, SealedSecret s, DateTime at) async { + Future saveSecret(String acsId, SealedSecret s, DateTime at) async { + if (perdeCorrida) return false; record = _copia(totp: TotpEnrollment(sealed: s, enabled: false, lastStep: null)); + return true; } @override - Future enable(String acsId, int step, DateTime at) async { + Future enable(String acsId, SealedSecret pending, int step, DateTime at) async { + if (perdeCorrida) return false; enabledCalls++; final t = record.totp!; record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: true, lastStep: step)); + return true; } @override - Future registerStep(String acsId, int step) async { + Future registerStep(String acsId, int step) async { + if (perdeCorrida) return false; lastRegisteredStep = step; final t = record.totp!; record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: t.enabled, lastStep: step)); + return true; } AcsCredentialRecord _copia({int? failedAttempts, DateTime? lockedUntil, TotpEnrollment? totp}) => @@ -189,6 +201,20 @@ void main() { expect(user.id, _acsId); }); + test('o store não avançou o passo (outra requisição gravou antes) → recusa', () async { + // Duas requisições com o mesmo código leram o mesmo lastStep e as duas + // passaram no verify; só a primeira avança a linha. A segunda não pode + // receber sessão (RFC 6238 §5.2: código já validado não vale de novo). + final m = await montar(); + m.store.perdeCorrida = true; + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0), + throwsA(isA()), + ); + expect(m.store.successfulLogins, 0); + expect(m.store.record.failedAttempts, 1); + }); + test('senha errada continua dando a mensagem única, com ou sem código', () async { final m = await montar(); await expectLater( @@ -248,6 +274,33 @@ void main() { expect(m.store.record.failedAttempts, 1); }); + test('confirm que perdeu a corrida (ativação já gravada por outra) é recusado', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + m.store.perdeCorrida = true; + await expectLater( + m.servico.confirmTotpEnrollment( + matricula: 'ACS-001', + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ), + throwsA(isA()), + ); + expect(m.store.enabledCalls, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('begin que perdeu a corrida para uma ativação concorrente é recusado', () async { + final m = await montar(comMfa: false); + m.store.perdeCorrida = true; + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha), + throwsA(isA()), + ); + }); + test('begin com senha errada é recusado como o login (mensagem única)', () async { final m = await montar(comMfa: false); await expectLater( From abb84383557da32f6004251eeacbbb1c066d8245 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:37:53 -0400 Subject: [PATCH 37/43] =?UTF-8?q?feat(acs):=20login=20com=20c=C3=B3digo=20?= =?UTF-8?q?TOTP=20e=20tela=20de=20ativa=C3=A7=C3=A3o=20da=20verifica=C3=A7?= =?UTF-8?q?=C3=A3o=20em=20duas=20etapas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 9 +- apps/CLAUDE.md | 2 + .../integration_test/full_journey_e2e.dart | 44 +++++++ apps/acs/integration_test/support/totp.dart | 34 ++++++ apps/acs/lib/app/app.dart | 45 +++++++- apps/acs/lib/app/mfa_enrollment_screen.dart | 109 ++++++++++++++++++ apps/acs/lib/core/network/backend_client.dart | 55 ++++++++- apps/acs/test/mfa_login_test.dart | 87 ++++++++++++++ apps/acs/test/support/fakes.dart | 23 ++++ apps/acs/test/support/totp.dart | 34 ++++++ apps/acs/test/text_scale_test.dart | 40 +++++++ apps/acs/test/totp_support_test.dart | 16 +++ 12 files changed, 491 insertions(+), 7 deletions(-) create mode 100644 apps/acs/integration_test/support/totp.dart create mode 100644 apps/acs/lib/app/mfa_enrollment_screen.dart create mode 100644 apps/acs/test/mfa_login_test.dart create mode 100644 apps/acs/test/support/totp.dart create mode 100644 apps/acs/test/totp_support_test.dart diff --git a/PROGRESS.md b/PROGRESS.md index 3587afb3..827a0890 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -567,9 +567,10 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. - **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). - **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). -- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, MFA/refresh token, iOS, botão da UBS. -- **Fora:** MFA/refresh token, iOS (`apps/acs/ios/` não existe). +- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, refresh token, iOS, botão da UBS. +- **Fora:** refresh token, iOS (`apps/acs/ios/` não existe). - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. +- **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo seguinte). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Migração para Serverpod @@ -778,7 +779,7 @@ nascimento e o código de 6 dígitos recebido, e o servidor só emite a sessão `spec/lgpd_design.md`. - **Refresh token rotativo continua ausente** (LGPD-RT06) — é o que permitiria voltar o TTL do paciente aos 15 minutos sem quebrar a experiência. -- **MFA/TOTP ausente** (achado F5 de `spec/security_assessment.md`). +- **MFA/TOTP do ACS: entregue em 2026-10-02** (achado F5 de `spec/security_assessment.md`; ver a entrada "MFA do ACS" do app). - **Sem limite de tentativas por origem (IP).** O teto de 5 verificações é por desafio e o intervalo de 60 s é por CPF; nada olha de onde vem a chamada — o mesmo vale para o pedido de código em si, que não tem limite nenhum. @@ -1292,7 +1293,7 @@ Plano: `docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md`, branch ` - **Revisão independente (subagente, 2026-09-30):** a primeira versão lia `myData` a cada login (dossiê decifrado + linha de auditoria falsa, inclusive onde nunca viria token); trocada pelo endpoint novo. Também: teste de que conceder não consulta de novo, teste do teto de 3 s (a resposta tardia é descartada), onboarding sem leitura à toa, comentário da constante no lugar certo. **Deferido:** empate exato de `timestamp` em `consent_logs` (o painel ordena só por tempo; o endpoint novo usa o desempate do servidor); revogação no intervalo entre resposta e token (ver `spec/lgpd_design.md`). - Testes: paciente 233, backend 433. - **Provado em 2026-09-30 no `emulator-5554` (AVD `Medium_Phone`):** `tool/live_check.dart` OK; `e2e.sh --keep --emulator` (smoke paciente e ACS) OK; `backend_connection_test` 7/7; `push_e2e.sh --negativos` saiu com 0 (entrega, token falso podado, Gorush parado, revogação). `ci_invariants.sh` OK e `flutter build apk --debug` sem `google-services.json` OK. A conferência do login OTP de paciente sem consentimento é coberta por teste de widget, não por execução no aparelho. -- **Segue aberto:** iOS/APNs, aparelho físico, Gorush hospedado, revisão jurídica dos termos 2026.1, backoffice que atende exclusão/correção, MFA/refresh token, menores deferidos do RF14, endpoint leve de consentimento (evita a auditoria de leitura por login). +- **Segue aberto:** iOS/APNs, aparelho físico, Gorush hospedado, revisão jurídica dos termos 2026.1, backoffice que atende exclusão/correção, refresh token, menores deferidos do RF14, endpoint leve de consentimento (evita a auditoria de leitura por login). ## Teste completo do paciente contra o banco de teste (2026-09-30) diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index af383113..c06ba71e 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -48,6 +48,8 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. O botão "Ligar para a UBS" (RF13) usa o mesmo discador com o telefone que `AcsBackend.ubsContact()` busca em `ubs.myContact`; sem telefone cadastrado, avisa e não liga. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto). +**ACS: MFA (2026-10-02).** O login institucional aceita o código TOTP: quando o servidor devolve `MfaRequiredException` (`MfaCodeRequired` no app), `LoginScreen` mostra `totp_field` (6 dígitos, só números) e reenvia matrícula+senha com `totpCode`; mudar matrícula ou senha descarta o campo. `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (`mfa_secret`, `mfa_code_field`, `mfa_confirm_button`; QR sempre branco com módulos escuros, por leitura), sem token e sem gravar o segredo. `test/support/totp.dart` e `integration_test/support/totp.dart` (cópias idênticas) geram o código RFC 6238 para teste; o e2e liga a MFA pelo RPC usando `acsCredentialFromRelay()` e entra com o código do passo seguinte (o da ativação já foi usado e o replay é barrado), por isso é o último teste de `full_journey_e2e.dart`. + **ACS: cache da microárea (RF08).** `MicroAreaDirectory` (`core/services/micro_area_directory.dart`) é a única regra de quando o cache serve: só no lugar de uma falha **recuperável** (`BackendFailure.isRecoverable`), só para o mesmo `userId|microAreaId` e em até 72 h (`microAreaCacheMaxAge`); recusa do servidor sobe como veio, e dono diferente ou lista vencida **apaga** o cache. `MicroAreaCacheStore` guarda nome, `isChronic` e condições na mesma base SQLCipher das visitas (schema v6, migração aditiva); gravar o cache nunca derruba a lista fresca. `SinalAcsApp(microAreaDirectory:)` é opcional e `null` significa sem cache (o Keystore não existe no `flutter test`): quem liga o de produção é o `main.dart` (`buildMicroAreaDirectory`), guardado por `test/micro_area_cache_ui_test.dart`. As telas Área e Visita mostram `micro_area_cache_notice(_area)` quando a lista é do cache. Política em `spec/lgpd_design.md` §5.11. ### Admin app (`apps/admin/`) diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index 542c4c09..beb9c862 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -34,6 +34,7 @@ import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' as api; import 'support/e2e_acs.dart'; +import 'support/totp.dart'; Future _pumpUntil(WidgetTester tester, bool Function() done, {Duration timeout = const Duration(seconds: 30)}) async { @@ -192,4 +193,47 @@ void main() { expect(String.fromCharCodes(bytes.take(16)), isNot(startsWith('SQLite format 3'))); await store.clear(); }); + + testWidgets('MFA: com a verificação ativa, o login pela tela pede e aceita o código', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final cliente = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(cliente.close); + final cred = await acsCredentialFromRelay(); + + // 1) Liga a MFA pelo RPC: matrícula + senha + o código do segredo recém-sorteado. + final inicio = await cliente.auth.beginTotpEnrollment(matricula: cred.matricula, password: cred.senha); + final segredo = base32Decode(inicio.secretBase32); + await cliente.auth.confirmTotpEnrollment( + matricula: cred.matricula, + password: cred.senha, + code: totpCode(segredo, DateTime.now()), + ); + + // 2) Login pela tela: matrícula + senha -> o app pede o código e o painel NÃO abre. + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('totp_field')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir sem o código'); + + // 3) O código da ativação já foi usado (replay barrado): entra com o do PASSO SEGUINTE, + // que a janela de ±1 aceita. + await tester.enterText( + find.byKey(const Key('totp_field')), + totpCode(segredo, DateTime.now().add(const Duration(seconds: 30))), + ); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => + find.byKey(const Key('login_button')).evaluate().isEmpty || find.byKey(const Key('login_error')).evaluate().isNotEmpty); + final erro = find.byKey(const Key('login_error')); + expect(erro, findsNothing, reason: erro.evaluate().isEmpty ? '' : (tester.widget(erro).data ?? '')); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); } diff --git a/apps/acs/integration_test/support/totp.dart b/apps/acs/integration_test/support/totp.dart new file mode 100644 index 00000000..f020bd45 --- /dev/null +++ b/apps/acs/integration_test/support/totp.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 7196133c..befb680f 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FilteringTextInputFormatter; import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:sinalacs_acs/app/acs_theme.dart'; import 'package:sinalacs_acs/app/invite_screen.dart'; +import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart'; import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/geo/location_cell.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; @@ -174,11 +176,20 @@ class _LoginScreenState extends State { // '123456' embutidos, e o botão navegava sem olhar para nenhum dos dois. final _matricula = TextEditingController(); final _senha = TextEditingController(); + final _totp = TextEditingController(); + bool _pedeCodigo = false; bool _busy = false; String? _error; @override - void dispose() { _matricula.dispose(); _senha.dispose(); super.dispose(); } + void dispose() { _matricula.dispose(); _senha.dispose(); _totp.dispose(); super.dispose(); } + + /// O código pertence à sessão que o pediu: mudar matrícula ou senha o descarta. + void _credencialMudou(String _) { + if (!_pedeCodigo) return; + setState(() => _pedeCodigo = false); + _totp.clear(); + } /// Autentica contra `auth.loginInstitutional` (RF07) e só então abre o /// painel. @@ -201,6 +212,7 @@ class _LoginScreenState extends State { final session = await BackendScope.of(context).login( matricula: matricula, senha: senha, + totpCode: _pedeCodigo ? _totp.text.trim() : null, ); final microAreaId = session.microAreaId; if (microAreaId == null) { @@ -228,6 +240,22 @@ class _LoginScreenState extends State { syncInterval: widget.syncInterval, ), )); + } on MfaCodeRequired { + if (!mounted) return; + setState(() { _pedeCodigo = true; _error = null; _busy = false; }); + } on MfaEnrollmentRequired { + if (!mounted) return; + setState(() => _busy = false); + final ativou = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MfaEnrollmentScreen( + backend: BackendScope.of(context), + matricula: matricula, + senha: senha, + ), + )); + if (ativou == true && mounted) { + setState(() => _pedeCodigo = true); + } } on BackendFailure catch (failure) { if (!mounted) return; setState(() { _busy = false; _error = failure.message; }); @@ -250,6 +278,7 @@ class _LoginScreenState extends State { TextField( key: const Key('matricula_field'), controller: _matricula, + onChanged: _credencialMudou, autofillHints: const [AutofillHints.username], decoration: const InputDecoration(labelText: 'Matrícula / CNS'), ), @@ -257,10 +286,24 @@ class _LoginScreenState extends State { TextField( key: const Key('senha_field'), controller: _senha, + onChanged: _credencialMudou, obscureText: true, autofillHints: const [AutofillHints.password], decoration: const InputDecoration(labelText: 'Senha de acesso'), ), + if (_pedeCodigo) ...[ + const SizedBox(height: 16), + TextField( + key: const Key('totp_field'), + controller: _totp, + keyboardType: TextInputType.number, + maxLength: 6, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código do autenticador (6 dígitos)'), + onSubmitted: (_) => _enter(), + ), + ], const SizedBox(height: 20), // Sem `Semantics` em volta, pelo mesmo motivo do login do paciente: // o `Text` do botão já é o nome acessível ("Entrar com credenciais") diff --git a/apps/acs/lib/app/mfa_enrollment_screen.dart b/apps/acs/lib/app/mfa_enrollment_screen.dart new file mode 100644 index 00000000..919373f4 --- /dev/null +++ b/apps/acs/lib/app/mfa_enrollment_screen.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show TotpEnrollmentStart; + +/// Ativação da verificação em duas etapas (RF07 / LGPD-RT06). +/// +/// Sem token: o ACS acabou de provar matrícula e senha. O segredo aparece só +/// aqui, em QR e em texto, e **não** é gravado no aparelho. +class MfaEnrollmentScreen extends StatefulWidget { + const MfaEnrollmentScreen({ + super.key, + required this.backend, + required this.matricula, + required this.senha, + }); + + final AcsBackend backend; + final String matricula; + final String senha; + + @override + State createState() => _MfaEnrollmentScreenState(); +} + +class _MfaEnrollmentScreenState extends State { + final _codigo = TextEditingController(); + TotpEnrollmentStart? _inicio; + String? _erro; + bool _ocupado = false; + + @override + void initState() { + super.initState(); + _iniciar(); + } + + @override + void dispose() { + _codigo.dispose(); + super.dispose(); + } + + Future _iniciar() async { + try { + final inicio = await widget.backend.beginTotpEnrollment(matricula: widget.matricula, senha: widget.senha); + if (mounted) setState(() => _inicio = inicio); + } on BackendFailure catch (falha) { + if (mounted) setState(() => _erro = falha.message); + } + } + + Future _confirmar() async { + if (_ocupado) return; + setState(() { _ocupado = true; _erro = null; }); + try { + await widget.backend.confirmTotpEnrollment( + matricula: widget.matricula, + senha: widget.senha, + code: _codigo.text.trim(), + ); + if (mounted) Navigator.of(context).pop(true); + } on BackendFailure catch (falha) { + if (mounted) setState(() { _erro = falha.message; _ocupado = false; }); + } + } + + @override + Widget build(BuildContext context) { + final inicio = _inicio; + return Scaffold( + appBar: AppBar(title: const Text('Verificação em duas etapas')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text('Leia o QR no aplicativo autenticador (ou digite a chave) e informe o código de 6 dígitos que ele mostrar.'), + const SizedBox(height: 16), + if (inicio == null && _erro == null) const Center(child: CircularProgressIndicator()), + if (inicio != null) ...[ + // Fundo branco com módulos escuros por exigência de leitura: não usa token de tema. + Center(child: QrImageView(data: inicio.otpauthUri, size: 200, backgroundColor: Colors.white)), + const SizedBox(height: 12), + SelectableText(inicio.secretBase32, key: const Key('mfa_secret')), + ], + if (_erro != null) + Semantics(liveRegion: true, child: Text(_erro!, key: const Key('mfa_error'), + style: TextStyle(color: Theme.of(context).colorScheme.error))), + const SizedBox(height: 16), + TextField( + key: const Key('mfa_code_field'), + controller: _codigo, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código de 6 dígitos'), + ), + const SizedBox(height: 12), + FilledButton( + key: const Key('mfa_confirm_button'), + onPressed: inicio == null || _ocupado ? null : _confirmar, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Ativar'), + ), + ], + ), + ); + } +} diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index bcaf17ab..1c8e3a1b 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -20,6 +20,16 @@ class BackendFailure implements Exception { String toString() => message; } +/// O ACS tem MFA ativa e a senha conferiu: falta o código do autenticador. +class MfaCodeRequired extends BackendFailure { + const MfaCodeRequired() : super('Informe o código do aplicativo autenticador.'); +} + +/// O servidor exige MFA e este ACS ainda não a ativou. +class MfaEnrollmentRequired extends BackendFailure { + const MfaEnrollmentRequired() : super('Ative a verificação em duas etapas antes de entrar.', isRecoverable: false); +} + /// Recusa um host de RPC que não esteja em **HTTPS** (RNF04/L-08). /// /// Existe porque a rede do sistema operacional **não** segura mais nada: a Task @@ -59,7 +69,17 @@ abstract class AcsBackend { /// A credencial fica **apenas em memória** — ver `_credentials` em /// [BackendClient] — para a reautenticação silenciosa que o app já fazia /// quando o token de 15 minutos expirava. Nada é gravado em disco. - Future login({required String matricula, required String senha}); + /// + /// [totpCode] é o código do autenticador (MFA); sem ele, um ACS com MFA ativa + /// recebe [MfaCodeRequired]. + Future login({required String matricula, required String senha, String? totpCode}); + + /// Começa a ativação da verificação em duas etapas. Sem token: vale a + /// matrícula e a senha. + Future beginTotpEnrollment({required String matricula, required String senha}); + + /// Conclui a ativação com o primeiro código do autenticador. + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}); /// Login de DESENVOLVIMENTO, para `tool/` e `integration_test/` contra a /// stack local. Só funciona com `ENABLE_DEV_LOGIN=true`; **não** é o caminho @@ -133,7 +153,15 @@ class MisconfiguredBackend implements AcsBackend { Never _recusar() => throw failure; @override - Future login({required String matricula, required String senha}) async => + Future login({required String matricula, required String senha, String? totpCode}) async => + _recusar(); + + @override + Future beginTotpEnrollment({required String matricula, required String senha}) async => + _recusar(); + + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) async => _recusar(); @override @@ -251,11 +279,13 @@ class BackendClient implements AcsBackend { Future login({ required String matricula, required String senha, + String? totpCode, }) async { final result = await _guard( () => _client.auth.loginInstitutional( matricula: matricula, password: senha, + totpCode: totpCode, ), ); @@ -272,6 +302,23 @@ class BackendClient implements AcsBackend { return session; } + /// Ativação da MFA: sem `_requireToken()` (ainda não há token) e sem gravar + /// a credencial — ela só vai para `_credentials` num [login] bem-sucedido. + @override + Future beginTotpEnrollment({ + required String matricula, + required String senha, + }) => + _guard(() => _client.auth.beginTotpEnrollment(matricula: matricula, password: senha)); + + @override + Future confirmTotpEnrollment({ + required String matricula, + required String senha, + required String code, + }) => + _guard(() => _client.auth.confirmTotpEnrollment(matricula: matricula, password: senha, code: code)); + /// Reautentica usando a credencial em memória. /// /// Sem credencial guardada não há como renovar: quem chama recebe uma falha @@ -420,6 +467,10 @@ class BackendClient implements AcsBackend { 'O acesso de desenvolvimento está desativado neste servidor.', isRecoverable: false, ); + } on MfaRequiredException { + throw const MfaCodeRequired(); + } on MfaEnrollmentRequiredException { + throw const MfaEnrollmentRequired(); } on AuthenticationFailedException catch (error) { // A mensagem vem do servidor de propósito: é ela que diferencia "senha // inválida" de "acesso bloqueado por tentativas" — e é igual para diff --git a/apps/acs/test/mfa_login_test.dart b/apps/acs/test/mfa_login_test.dart new file mode 100644 index 00000000..2df33595 --- /dev/null +++ b/apps/acs/test/mfa_login_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'support/fakes.dart'; + +Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); +} + +void main() { + // 1. MFA exigida: depois de matrícula+senha, aparece o campo de código e o painel NÃO abre. + testWidgets('pede o código do autenticador depois de matrícula e senha', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('totp_field')), findsOneWidget); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não abriu'); + }); + + // 2. Código certo: entra, e o app reenviou matrícula+senha COM o código. + testWidgets('com o código certo, entra', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(backend.lastTotpCode, '123456'); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); + }); + + // 3. Código errado: erro visível, painel fechado, campo continua. + testWidgets('com o código errado, mostra o erro e fica no login', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '000000'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + }); + + // 4. Servidor exige MFA e o ACS não a tem: vai para a tela de ativação, com segredo e campo de código. + testWidgets('sem MFA ativada, leva à ativação e confirma com o código', (tester) async { + final backend = FakeAcsBackend()..mfaEnrollmentRequired = true; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.textContaining('GEZDGNBVGY3TQOJQ'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('mfa_code_field')), '654321'); + await tester.tap(find.byKey(const Key('mfa_confirm_button'))); + await tester.pumpAndSettle(); + + expect(backend.confirmedCode, '654321'); + // Voltou ao login, pronto para entrar de novo (agora com MFA). + expect(find.byKey(const Key('login_button')), findsOneWidget); + }); + + // 5. O código é só dígitos: o campo recusa letras e passa de 6. + testWidgets('o campo de código aceita só 6 dígitos', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '12ab34567'); + + final campo = tester.widget(find.byKey(const Key('totp_field'))); + expect(campo.controller!.text, '123456', reason: 'letras caem fora e o 7º dígito não entra'); + expect(campo.keyboardType, TextInputType.number); + }); +} diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index f4c24009..0a1e34ed 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -55,13 +55,36 @@ class FakeAcsBackend implements AcsBackend { /// branco não chamou nada". ({String matricula, String senha})? lastCredentials; + /// Simula o servidor com MFA ativa: sem [totpCode] igual a [expectedTotpCode], levanta [MfaCodeRequired]. + String? expectedTotpCode; + bool mfaEnrollmentRequired = false; + String? lastTotpCode; + TotpEnrollmentStart enrollmentStart = + TotpEnrollmentStart(secretBase32: 'GEZDGNBVGY3TQOJQ', otpauthUri: 'otpauth://totp/SinalACS:ACS-001?secret=GEZDGNBVGY3TQOJQ'); + String? confirmedCode; + + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) async { + confirmedCode = code; + } + + @override + Future beginTotpEnrollment({required String matricula, required String senha}) async => + enrollmentStart; + @override Future login({ required String matricula, required String senha, + String? totpCode, }) async { loginCount++; lastCredentials = (matricula: matricula, senha: senha); + lastTotpCode = totpCode; + if (mfaEnrollmentRequired) throw const MfaEnrollmentRequired(); + if (expectedTotpCode != null && totpCode != expectedTotpCode) { + throw totpCode == null ? const MfaCodeRequired() : const BackendFailure('Código de verificação inválido.', isRecoverable: false); + } return _issueSession(); } diff --git a/apps/acs/test/support/totp.dart b/apps/acs/test/support/totp.dart new file mode 100644 index 00000000..f020bd45 --- /dev/null +++ b/apps/acs/test/support/totp.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} diff --git a/apps/acs/test/text_scale_test.dart b/apps/acs/test/text_scale_test.dart index c548c6bb..7d670e42 100644 --- a/apps/acs/test/text_scale_test.dart +++ b/apps/acs/test/text_scale_test.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart'; + +import 'support/fakes.dart'; import 'support/layout_harness.dart'; /// WCAG 1.4.4 (RNF05): o conteúdo precisa sobreviver a 200% de escala de texto. @@ -29,4 +33,40 @@ void main() { expect(alturaAmpliada, greaterThan(alturaPadrao)); esperarSemEstouro(tester, 'cabeçalho com fonte a 200%'); }); + + // Telas novas da MFA: o campo de código no login e a ativação (QR + chave + campo). + for (final escala in const [1.3, 2.0]) { + final rotulo = '${(escala * 100).toInt()}%'; + + testWidgets('login com o campo de código não estoura com fonte a $rotulo em 360x800', (tester) async { + redimensionar(tester, const Size(360, 800), escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); + await tester.pump(); + await tester.tap(entrar); + await assentar(tester); + + expect(find.byKey(const Key('totp_field')), findsOneWidget); + await percorrerTelaInteira(tester, 'login com código a $rotulo'); + }); + + testWidgets('tela de ativação da MFA não estoura com fonte a $rotulo em 360x800', (tester) async { + redimensionar(tester, const Size(360, 800), escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget(MaterialApp( + home: MfaEnrollmentScreen(backend: FakeAcsBackend(), matricula: 'ACS-001', senha: 'senha-sintetica'), + )); + await assentar(tester); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + await percorrerTelaInteira(tester, 'ativação da MFA a $rotulo'); + }); + } } diff --git a/apps/acs/test/totp_support_test.dart b/apps/acs/test/totp_support_test.dart new file mode 100644 index 00000000..3cc161fc --- /dev/null +++ b/apps/acs/test/totp_support_test.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'support/totp.dart'; + +void main() { + test('vetor RFC 6238: SHA-1, t=59 s dá 287082', () { + final segredo = ascii.encode('12345678901234567890'); + expect(totpCode(segredo, DateTime.fromMillisecondsSinceEpoch(59000, isUtc: true)), '287082'); + }); + + test('base32Decode devolve os 20 bytes ASCII do segredo de teste', () { + expect(base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), ascii.encode('12345678901234567890')); + }); +} From 69c19d43000c23f6fe0d14a554516c929e95d4f5 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:48:56 -0400 Subject: [PATCH 38/43] =?UTF-8?q?fix(acs):=20MFA=20expira=20a=20sess=C3=A3?= =?UTF-8?q?o=20sem=20renova=C3=A7=C3=A3o=20silenciosa,=20e2e=20determin?= =?UTF-8?q?=C3=ADstico=20do=20TOTP=20e=20dicas=20da=20ativa=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 2 +- .../integration_test/full_journey_e2e.dart | 20 ++++--- apps/acs/lib/app/app.dart | 45 +++++++++++++- apps/acs/lib/app/mfa_enrollment_screen.dart | 10 ++++ apps/acs/lib/core/network/backend_client.dart | 35 +++++++++-- apps/acs/test/mfa_login_test.dart | 40 +++++++++++++ apps/acs/test/session_expiry_mfa_test.dart | 59 +++++++++++++++++++ apps/acs/test/support/fake_rpc_server.dart | 12 ++++ apps/acs/test/support/fakes.dart | 15 ++++- apps/acs/test/totp_support_test.dart | 11 ++++ 10 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 apps/acs/test/session_expiry_mfa_test.dart diff --git a/PROGRESS.md b/PROGRESS.md index 827a0890..f117f808 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -570,7 +570,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, refresh token, iOS, botão da UBS. - **Fora:** refresh token, iOS (`apps/acs/ios/` não existe). - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. -- **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo seguinte). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). +- **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa digitar matrícula + senha + código de novo quando a sessão de 15 minutos vence, porque ainda não existe refresh token** (o código TOTP é de uso único e não dá para renovar em silêncio): `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória, falha de forma não recuperável e `AcsBackend.onSessionExpired` leva o painel de volta ao login com o aviso; as visitas pendentes ficam na fila (disco) e a sincronização que falhou as mantém pendentes. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo seguinte). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Migração para Serverpod diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index beb9c862..27e747f0 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -207,6 +207,8 @@ void main() { // 1) Liga a MFA pelo RPC: matrícula + senha + o código do segredo recém-sorteado. final inicio = await cliente.auth.beginTotpEnrollment(matricula: cred.matricula, password: cred.senha); final segredo = base32Decode(inicio.secretBase32); + int passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final passoDaAtivacao = passo(DateTime.now()); await cliente.auth.confirmTotpEnrollment( matricula: cred.matricula, password: cred.senha, @@ -221,17 +223,21 @@ void main() { await _pumpUntil(tester, () => find.byKey(const Key('totp_field')).evaluate().isNotEmpty); expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir sem o código'); - // 3) O código da ativação já foi usado (replay barrado): entra com o do PASSO SEGUINTE, - // que a janela de ±1 aceita. - await tester.enterText( - find.byKey(const Key('totp_field')), - totpCode(segredo, DateTime.now().add(const Duration(seconds: 30))), - ); + // 3) O código da ativação já foi usado (replay barrado). Espera o relógio do + // aparelho passar para um passo MAIOR que o da ativação e entra com o código + // do passo ATUAL: fica dentro da janela de +-1 do servidor para defasagem + // de relógio nos dois sentidos, sem apostar no passo seguinte. + await _pumpUntil(tester, () => passo(DateTime.now()) > passoDaAtivacao, timeout: const Duration(seconds: 40)); + final agora = DateTime.now(); + final codigo = totpCode(segredo, agora); + await tester.enterText(find.byKey(const Key('totp_field')), codigo); await tester.tap(find.byKey(const Key('login_button'))); await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty || find.byKey(const Key('login_error')).evaluate().isNotEmpty); final erro = find.byKey(const Key('login_error')); - expect(erro, findsNothing, reason: erro.evaluate().isEmpty ? '' : (tester.widget(erro).data ?? '')); + expect(erro, findsNothing, + reason: '${erro.evaluate().isEmpty ? '' : (tester.widget(erro).data ?? '')} ' + '(relógio do aparelho ${agora.toUtc().toIso8601String()}, passo ${passo(agora)}, ativação no passo $passoDaAtivacao)'); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(seconds: 1)); diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index befb680f..1cda1f62 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -156,8 +156,11 @@ class LoginScreen extends StatefulWidget { this.initialAlert, this.initialPosition, this.syncInterval, + this.aviso, }); + /// Aviso fixo no topo do formulário (ex.: sessão expirada). + final String? aviso; final AlertFeed Function(AlertQueue queue)? feedBuilder; final OfflineVisitQueue visitQueue; final VisitPullService visitPullService; @@ -180,6 +183,13 @@ class _LoginScreenState extends State { bool _pedeCodigo = false; bool _busy = false; String? _error; + String? _aviso; + + @override + void initState() { + super.initState(); + _aviso = widget.aviso; + } @override void dispose() { _matricula.dispose(); _senha.dispose(); _totp.dispose(); super.dispose(); } @@ -206,7 +216,7 @@ class _LoginScreenState extends State { return; } - setState(() { _busy = true; _error = null; }); + setState(() { _busy = true; _error = null; _aviso = null; }); try { final session = await BackendScope.of(context).login( @@ -254,7 +264,7 @@ class _LoginScreenState extends State { ), )); if (ativou == true && mounted) { - setState(() => _pedeCodigo = true); + setState(() { _pedeCodigo = true; _aviso = 'Aguarde o próximo código do autenticador.'; }); } } on BackendFailure catch (failure) { if (!mounted) return; @@ -291,6 +301,10 @@ class _LoginScreenState extends State { autofillHints: const [AutofillHints.password], decoration: const InputDecoration(labelText: 'Senha de acesso'), ), + if (_aviso != null) Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics(liveRegion: true, child: Text(_aviso!, key: const Key('login_aviso'), textAlign: TextAlign.center)), + ), if (_pedeCodigo) ...[ const SizedBox(height: 16), TextField( @@ -491,10 +505,36 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver // mesmo motivo de VisitRegistrationScreen._patientsRequested. if (!_patientsRequested) { _patientsRequested = true; + _backend = BackendScope.of(context); + _backend!.onSessionExpired = _sessionExpired; _loadMicroAreaPatients(); } } + AcsBackend? _backend; + + /// A sessão venceu e não renova sozinha (MFA, sem refresh token): volta ao + /// login. A fila offline é a mesma instância e segue com as visitas pendentes. + void _sessionExpired() { + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => LoginScreen( + visitQueue: widget.visitQueue, + themeController: widget.themeController, + visitPullService: widget.visitPullService, + directory: widget.directory, + feedBuilder: widget.feedBuilder, + initialAlert: widget.initialAlert, + initialPosition: widget.initialPosition, + syncInterval: widget.syncInterval, + aviso: 'Sua sessão expirou. Entre novamente com o código do autenticador.', + ), + ), + (_) => false, + ); + } + Future _loadMicroAreaPatients() async { final backend = BackendScope.of(context); setState(() { _loadingMicroAreaPatients = true; _microAreaPatientsError = null; _microAreaFromCache = false; }); @@ -789,6 +829,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver @override void dispose() { + if (_backend?.onSessionExpired == _sessionExpired) _backend!.onSessionExpired = null; _reconnectTimer?.cancel(); _periodicSyncTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); diff --git a/apps/acs/lib/app/mfa_enrollment_screen.dart b/apps/acs/lib/app/mfa_enrollment_screen.dart index 919373f4..f560ef84 100644 --- a/apps/acs/lib/app/mfa_enrollment_screen.dart +++ b/apps/acs/lib/app/mfa_enrollment_screen.dart @@ -43,6 +43,7 @@ class _MfaEnrollmentScreenState extends State { } Future _iniciar() async { + setState(() => _erro = null); try { final inicio = await widget.backend.beginTotpEnrollment(matricula: widget.matricula, senha: widget.senha); if (mounted) setState(() => _inicio = inicio); @@ -86,6 +87,15 @@ class _MfaEnrollmentScreenState extends State { if (_erro != null) Semantics(liveRegion: true, child: Text(_erro!, key: const Key('mfa_error'), style: TextStyle(color: Theme.of(context).colorScheme.error))), + if (inicio == null && _erro != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + key: const Key('mfa_retry_button'), + onPressed: _iniciar, + child: const Text('Tentar de novo'), + ), + ), const SizedBox(height: 16), TextField( key: const Key('mfa_code_field'), diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index 1c8e3a1b..15bb645a 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -64,6 +64,11 @@ abstract class AcsBackend { bool get isAuthenticated; + /// Chamado quando a sessão venceu e **não** dá para renová-la sozinho (MFA: + /// o código do autenticador não se reaproveita e não há refresh token). A UI + /// leva a pessoa de volta ao login. + void Function()? onSessionExpired; + /// Autentica o ACS com matrícula e senha (RF07). /// /// A credencial fica **apenas em memória** — ver `_credentials` em @@ -142,6 +147,12 @@ class MisconfiguredBackend implements AcsBackend { /// O motivo, como [requireSecureHost] o produziu. final BackendFailure failure; + @override + void Function()? get onSessionExpired => null; + + @override + set onSessionExpired(void Function()? _) {} + @override AuthSession? get session => null; @@ -250,6 +261,9 @@ class BackendClient implements AcsBackend { @override AuthSession? get session => _session; + @override + void Function()? onSessionExpired; + @override bool get isAuthenticated { final current = _session; @@ -331,10 +345,23 @@ class BackendClient implements AcsBackend { isRecoverable: false, ); } - return login( - matricula: credentials.matricula, - senha: credentials.senha, - ); + try { + return await login( + matricula: credentials.matricula, + senha: credentials.senha, + ); + } on MfaCodeRequired { + // Com MFA a renovação silenciosa é impossível (o código TOTP é de uso + // único e ainda não há refresh token): esquece a credencial em vez de + // insistir e manda a pessoa entrar de novo. As visitas seguem pendentes. + _credentials = null; + _session = null; + onSessionExpired?.call(); + throw const BackendFailure( + 'Sua sessão expirou. Entre novamente com o código do autenticador.', + isRecoverable: false, + ); + } } /// Login de desenvolvimento, para `tool/` e `integration_test/`. diff --git a/apps/acs/test/mfa_login_test.dart b/apps/acs/test/mfa_login_test.dart index 2df33595..a2ddcd37 100644 --- a/apps/acs/test/mfa_login_test.dart +++ b/apps/acs/test/mfa_login_test.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar; Future entrar(WidgetTester tester) async { await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); @@ -67,6 +69,7 @@ void main() { await tester.pumpAndSettle(); expect(backend.confirmedCode, '654321'); + expect(find.text('Aguarde o próximo código do autenticador.'), findsOneWidget); // Voltou ao login, pronto para entrar de novo (agora com MFA). expect(find.byKey(const Key('login_button')), findsOneWidget); }); @@ -84,4 +87,41 @@ void main() { expect(campo.controller!.text, '123456', reason: 'letras caem fora e o 7º dígito não entra'); expect(campo.keyboardType, TextInputType.number); }); + + testWidgets('falha ao iniciar a ativação mostra o erro e deixa tentar de novo', (tester) async { + final backend = FakeAcsBackend() + ..mfaEnrollmentRequired = true + ..enrollmentFailuresLeft = 1; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_error')), findsOneWidget); + expect(find.byKey(const Key('mfa_secret')), findsNothing); + + await tester.tap(find.byKey(const Key('mfa_retry_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.byKey(const Key('mfa_error')), findsNothing); + }); + + testWidgets('o painel volta ao login com o aviso, sem perder a fila', (tester) async { + final fake = FakeAcsBackend(); + final fila = OfflineVisitQueue(); + await fila.add(OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await tester.pumpWidget(SinalAcsApp(backend: fake, visitQueue: fila, feedBuilder: (q) => FakeAlertFeed(q))); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); + + fake.onSessionExpired!(); + await assentar(tester); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text('Sua sessão expirou. Entre novamente com o código do autenticador.'), findsOneWidget); + expect(fila.pendingCount, 1); + }); } diff --git a/apps/acs/test/session_expiry_mfa_test.dart b/apps/acs/test/session_expiry_mfa_test.dart new file mode 100644 index 00000000..9f6a5f5c --- /dev/null +++ b/apps/acs/test/session_expiry_mfa_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fake_rpc_server.dart'; + +/// Sem refresh token, a renovação silenciosa de 15 minutos não pode reenviar a +/// senha sozinha para um ACS com MFA: o código TOTP não se reaproveita. +void main() { + late FakeRpcServer server; + late BackendClient backend; + + setUp(() async { + server = await FakeRpcServer.start() + ..mfaRequired = true + ..tokenLifetime = const Duration(minutes: -1); + backend = BackendClient(host: server.host); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + const mensagem = 'Sua sessão expirou. Entre novamente com o código do autenticador.'; + + test('renovação sem código falha de forma NÃO recuperável, avisa a UI e esquece a credencial', () async { + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica', totpCode: '123456'); + expect(server.loginCount, 1); + + await expectLater( + backend.listPatients(), + throwsA(isA() + .having((f) => f.message, 'message', mensagem) + .having((f) => f.isRecoverable, 'isRecoverable', isFalse)), + ); + expect(avisos, 1); + expect(server.loginCount, 2, reason: 'uma tentativa de renovação, recusada pela MFA'); + + // A credencial foi descartada: nova chamada não bate mais no servidor. + await expectLater(backend.listPatients(), throwsA(isA())); + expect(server.loginCount, 2); + }); + + test('visita pendente continua pendente quando a sessão MFA expira', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica', totpCode: '123456'); + final fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend)); + await fila.add(OfflineVisitRecord(patientId: '00000000-0000-4000-8000-0000000000a1', risk: 'red', status: 'PENDENTE')); + + final resultado = await fila.sync(); + + expect(resultado.kind, SyncOutcomeKind.error); + expect(resultado.message, mensagem); + expect(fila.pendingCount, 1); + }); +} diff --git a/apps/acs/test/support/fake_rpc_server.dart b/apps/acs/test/support/fake_rpc_server.dart index b831ded1..8490f7b4 100644 --- a/apps/acs/test/support/fake_rpc_server.dart +++ b/apps/acs/test/support/fake_rpc_server.dart @@ -53,6 +53,10 @@ class FakeRpcServer { /// formato que o backend real usa (`AuthenticationFailedException`). String? rejectWith; + /// Simula um ACS com MFA ativa: `loginInstitutional` sem `totpCode` recusa + /// com `MfaRequiredException`, como o backend real. + bool mfaRequired = false; + /// Faz `generateEnrollmentToken` recusar como o backend recusa paciente de /// outra microárea (`AlertPermissionException`, INV-01). bool rejectInviteWithPermission = false; @@ -93,6 +97,14 @@ class FakeRpcServer { return; } + if (method == 'loginInstitutional' && mfaRequired && decoded['totpCode'] == null) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'MfaRequiredException', + 'data': {'message': 'Informe o código do autenticador.'}, + }); + return; + } + if (method == 'generateEnrollmentToken' && rejectInviteWithPermission) { await _respond(request, HttpStatus.badRequest, { 'className': 'AlertPermissionException', diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 0a1e34ed..6622f698 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -55,6 +55,9 @@ class FakeAcsBackend implements AcsBackend { /// branco não chamou nada". ({String matricula, String senha})? lastCredentials; + @override + void Function()? onSessionExpired; + /// Simula o servidor com MFA ativa: sem [totpCode] igual a [expectedTotpCode], levanta [MfaCodeRequired]. String? expectedTotpCode; bool mfaEnrollmentRequired = false; @@ -69,8 +72,16 @@ class FakeAcsBackend implements AcsBackend { } @override - Future beginTotpEnrollment({required String matricula, required String senha}) async => - enrollmentStart; + Future beginTotpEnrollment({required String matricula, required String senha}) async { + if (enrollmentFailuresLeft > 0) { + enrollmentFailuresLeft--; + throw const BackendFailure('Sem conexão com o servidor.'); + } + return enrollmentStart; + } + + /// Quantas chamadas a [beginTotpEnrollment] ainda falham antes de funcionar. + int enrollmentFailuresLeft = 0; @override Future login({ diff --git a/apps/acs/test/totp_support_test.dart b/apps/acs/test/totp_support_test.dart index 3cc161fc..777da9aa 100644 --- a/apps/acs/test/totp_support_test.dart +++ b/apps/acs/test/totp_support_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -13,4 +14,14 @@ void main() { test('base32Decode devolve os 20 bytes ASCII do segredo de teste', () { expect(base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), ascii.encode('12345678901234567890')); }); + + test('vetor RFC 6238: SHA-1, t=1111111109 s dá 081804', () { + final segredo = ascii.encode('12345678901234567890'); + expect(totpCode(segredo, DateTime.fromMillisecondsSinceEpoch(1111111109000, isUtc: true)), '081804'); + }); + + test('as duas cópias do gerador (test/ e integration_test/) são idênticas byte a byte', () { + expect(File('integration_test/support/totp.dart').readAsBytesSync(), + File('test/support/totp.dart').readAsBytesSync()); + }); } From 38beea276b022b6f23596a1097facd09c97cdc95 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:54:18 -0400 Subject: [PATCH 39/43] feat(infra): broker MQTT exige certificado de cliente (mTLS), mantendo senha e ACL; backend apresenta o seu --- .env.example | 9 +++ CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- .../lib/src/config/app_config.dart | 30 ++++++++- .../mqtt/mqtt_alert_dispatcher.dart | 10 ++- .../test/unit/app_config_test.dart | 56 +++++++++++++++++ docker-compose.yml | 3 + infra/docker/mosquitto/init.sh | 26 ++++++++ infra/docker/mosquitto/mosquitto.conf | 3 + scripts/qa/mtls_invariants.sh | 61 +++++++++++++++++++ spec/stack.md | 2 + 11 files changed, 200 insertions(+), 4 deletions(-) create mode 100755 scripts/qa/mtls_invariants.sh diff --git a/.env.example b/.env.example index f6db43c3..abd83a11 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,15 @@ MQTT_ACS_PASSWORD= # MQTT_CERT_SAN_EXTRA=IP:192.168.0.10 MQTT_CERT_SAN_EXTRA= +# mTLS do broker: o backend apresenta um certificado de cliente (CN = usuário +# MQTT, emitido pelo mosquitto-init com a CA de desenvolvimento). O +# docker-compose.yml já define as duas variáveis para o container do servidor +# (/app/certs/backend.crt e backend.key); só precisam vir aqui para rodar o +# servidor fora do Compose. Vêm sempre juntas; fora de development com +# MQTT_USE_TLS=true são obrigatórias. A senha MQTT continua valendo. +# MQTT_CLIENT_CERT_PATH=infra/docker/mosquitto/runtime/certs/backend.crt +# MQTT_CLIENT_KEY_PATH=infra/docker/mosquitto/runtime/certs/backend.key + # SANs adicionais no certificado do RPC, que o Traefik apresenta em :443 # (RNF04). Mesma necessidade e mesmo formato do MQTT_CERT_SAN_EXTRA acima, mas # para a outra CA: a do RPC é SEPARADA da do broker — ver docs/README.md. diff --git a/CLAUDE.md b/CLAUDE.md index 4af7250d..122264cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL). Still missing: MFA and refresh token (deliberately deferred — see `PROGRESS.md`), mTLS on the broker (no client certificates), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). +SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL). Still missing: MFA and refresh token (deliberately deferred — see `PROGRESS.md`), mTLS in production (the broker requires a client certificate in dev, `scripts/qa/mtls_invariants.sh`; per-device provisioning is still missing), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). Two core flows: - **Paciente (patient)**: passwordless auth (OTP), urgency alert, structured triage, request status tracking, "Meus Dados" (LGPD). diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 89fc6423..665c71c0 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -36,7 +36,7 @@ serverpod create-migration Run the server directly: `dart run backend/sinalacs_server/bin/main.dart`. Serverpod reads `config/*.yaml` plus `config/passwords.yaml`, and every setting can be overridden by environment variable: `SERVERPOD_DATABASE_HOST`/`_PORT`/`_NAME`/`_USER`/`_PASSWORD`/`_REQUIRE_SSL`, `SERVERPOD_APPLY_MIGRATIONS` (applies migrations at boot — this is what replaced the manual `psql` steps), `SERVERPOD_REDIS_ENABLED` (Redis is optional and off), and `SERVERPOD_INSIGHTS_SERVER_PORT` (remapped to 8083 in `docker-compose.yml`, because Serverpod's default 8081 is the Traefik dashboard here). On the API server specifically, `config/development.yaml` keeps `port: 8080` — the process still listens on cleartext 8080 **inside** the container — while `publicHost`/`publicPort`/`publicScheme` are `localhost`/`443`/`https`. Those three describe the **declared** public address (the encrypted URL Traefik terminates), not the listener and not the address actually in use: no client here reads them, since both apps build the client from `--dart-define`, and the route that would serve them (`AppConfigRoute` in `lib/server.dart`) lives on the webServer (8082), which the compose neither publishes nor routes. And note **which** config file actually serves: the image runs in production mode — the Dockerfile sets `ENV runmode=production` and the entrypoint is `./server --mode=$runmode` — so the running backend reads `config/production.yaml`, not the `development.yaml` quoted above; there, the `apiServer` block keeps Serverpod's placeholder `publicHost: api.examplepod.com` with `publicPort: 443`, `publicScheme: https` and `port: 8080`. The paragraph above is therefore true about the file, but the process in the container does **not** declare `localhost:443` — it declares a hostname nobody resolves. Only Traefik publishes a port for the RPC — `443`, per RNF04/L-08; cleartext 8080 used to be published on the host and no longer is. -MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fromEnvironment()` in `backend/sinalacs_server/lib/src/config/app_config.dart`: `MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`, plus `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `APP_ENV`, `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off), `CPF_HASH_PEPPER` (RF01 — the key of the HMAC over the CPF and over the OTP code; the server and the seed writers must share it) and `SMS_GATEWAY` (the OTP delivery channel: it defaults to `log` in `development`, is **required** elsewhere, and `log` — the only gateway implemented today — is refused outside `development` by name, since it sends no SMS at all; the development gateway writes the code to the server log, and nothing else of the signature). +MQTT is not part of Serverpod and keeps its own env vars, read by `AppConfig.fromEnvironment()` in `backend/sinalacs_server/lib/src/config/app_config.dart`: `MQTT_BROKER`/`MQTT_USERNAME`/`MQTT_PASSWORD`/`MQTT_USE_TLS`/`MQTT_CA_CERT_PATH`/`MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` (certificado de cliente do mTLS do broker; só um dos dois definido é erro de boot, e fora de `development` com `MQTT_USE_TLS=true` os dois são obrigatórios), plus `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `APP_ENV`, `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off), `CPF_HASH_PEPPER` (RF01 — the key of the HMAC over the CPF and over the OTP code; the server and the seed writers must share it) and `SMS_GATEWAY` (the OTP delivery channel: it defaults to `log` in `development`, is **required** elsewhere, and `log` — the only gateway implemented today — is refused outside `development` by name, since it sends no SMS at all; the development gateway writes the code to the server log, and nothing else of the signature). Outside `development`, boot fails fast when `JWT_SECRET`, `AUDIT_CHAIN_SECRET` or `CPF_HASH_PEPPER` is absent, empty, blank, or equal to its own development fallback (`AppConfig.developmentJwtSecret` / `AppConfig.developmentAuditChainSecret` / `AppConfig.developmentCpfHashPepper`) — those fallbacks live in versioned code, so they're public, and `JWT_SECRET` signs tokens that carry role and micro-area, `AUDIT_CHAIN_SECRET` keys the `audit_logs` hash chain while `CPF_HASH_PEPPER` keys the HMAC over the CPF and over the OTP code. They are deliberately independent secrets — rotating one must not affect the others. The shared validation logic is `_resolveSecret`, covered by `test/unit/app_config_test.dart`; `AppConfig.fromMap()` exists so the rules are testable without touching `Platform.environment`. diff --git a/backend/sinalacs_server/lib/src/config/app_config.dart b/backend/sinalacs_server/lib/src/config/app_config.dart index 752909e9..1f10959e 100644 --- a/backend/sinalacs_server/lib/src/config/app_config.dart +++ b/backend/sinalacs_server/lib/src/config/app_config.dart @@ -17,6 +17,8 @@ class AppConfig { required this.mqttPassword, required this.mqttUseTls, required this.mqttCaCertificatePath, + this.mqttClientCertificatePath, + this.mqttClientKeyPath, required this.appEnv, required this.enableDevLogin, this.requireAcsMfa = false, @@ -85,6 +87,12 @@ class AppConfig { final String? mqttPassword; final bool mqttUseTls; final String? mqttCaCertificatePath; + + /// Certificado e chave de CLIENTE para o mTLS do broker (`MQTT_CLIENT_CERT_PATH` + /// e `MQTT_CLIENT_KEY_PATH`). Vêm sempre juntos; fora de `development` com + /// TLS são obrigatórios. + final String? mqttClientCertificatePath; + final String? mqttClientKeyPath; final String appEnv; final bool enableDevLogin; @@ -164,6 +172,9 @@ class AppConfig { factory AppConfig.fromEnvironment() => AppConfig.fromMap(Platform.environment); + static String? _blankToNull(String? value) => + (value == null || value.trim().isEmpty) ? null : value; + /// Mesma leitura, a partir de um mapa qualquer. /// /// `Platform.environment` não é substituível dentro do processo, então as @@ -172,6 +183,21 @@ class AppConfig { factory AppConfig.fromMap(Map environment) { final appEnv = environment['APP_ENV'] ?? 'development'; + final mqttUseTls = environment['MQTT_USE_TLS'] == 'true'; + final mqttClientCert = _blankToNull(environment['MQTT_CLIENT_CERT_PATH']); + final mqttClientKey = _blankToNull(environment['MQTT_CLIENT_KEY_PATH']); + if ((mqttClientCert == null) != (mqttClientKey == null)) { + throw StateError( + 'MQTT_CLIENT_CERT_PATH e MQTT_CLIENT_KEY_PATH devem vir juntos.', + ); + } + if (appEnv != 'development' && mqttUseTls && mqttClientCert == null) { + throw StateError( + 'MQTT_CLIENT_CERT_PATH e MQTT_CLIENT_KEY_PATH são obrigatórios ' + 'fora de development com MQTT_USE_TLS=true (mTLS do broker).', + ); + } + return AppConfig( mqttBroker: environment['MQTT_BROKER'] ?? 'localhost:1883', jwtSecret: _resolveSecret( @@ -202,8 +228,10 @@ class AppConfig { ), mqttUsername: environment['MQTT_USERNAME'], mqttPassword: environment['MQTT_PASSWORD'], - mqttUseTls: environment['MQTT_USE_TLS'] == 'true', + mqttUseTls: mqttUseTls, mqttCaCertificatePath: environment['MQTT_CA_CERT_PATH'], + mqttClientCertificatePath: mqttClientCert, + mqttClientKeyPath: mqttClientKey, appEnv: appEnv, enableDevLogin: environment['ENABLE_DEV_LOGIN'] == 'true', requireAcsMfa: resolveRequireAcsMfa( diff --git a/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart b/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart index 25743719..237268fc 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart @@ -58,8 +58,16 @@ class MqttAlertDispatcher implements AlertPublisher { .withWillQos(MqttQos.atLeastOnce); if (_config.mqttUseTls && _config.mqttCaCertificatePath != null) { - client.securityContext = SecurityContext(withTrustedRoots: false) + final contexto = SecurityContext(withTrustedRoots: false) ..setTrustedCertificates(_config.mqttCaCertificatePath!); + final cert = _config.mqttClientCertificatePath; + final chave = _config.mqttClientKeyPath; + if (cert != null && chave != null) { + contexto + ..useCertificateChain(cert) + ..usePrivateKey(chave); + } + client.securityContext = contexto; } await client.connect(_config.mqttUsername, _config.mqttPassword); diff --git a/backend/sinalacs_server/test/unit/app_config_test.dart b/backend/sinalacs_server/test/unit/app_config_test.dart index 355b25ee..fe750372 100644 --- a/backend/sinalacs_server/test/unit/app_config_test.dart +++ b/backend/sinalacs_server/test/unit/app_config_test.dart @@ -363,6 +363,62 @@ void main() { }); }); + group('certificado de cliente MQTT (mTLS)', () { + Map producao({bool tls = true, String? cert, String? key}) => { + 'APP_ENV': 'production', + 'JWT_SECRET': 'a' * 64, + 'AUDIT_CHAIN_SECRET': 'b' * 64, + 'HEALTH_DATA_ENCRYPTION_KEY': 'c' * 64, + 'CPF_HASH_PEPPER': 'd' * 64, + 'SMS_GATEWAY': 'log', + if (tls) 'MQTT_USE_TLS': 'true', + 'MQTT_CLIENT_CERT_PATH': ?cert, + 'MQTT_CLIENT_KEY_PATH': ?key, + }; + + test('só o certificado, sem a chave, é erro', () { + expect( + () => AppConfig.fromMap({'MQTT_CLIENT_CERT_PATH': '/c/backend.crt'}), + throwsA(isA().having( + (e) => e.message, 'message', contains('devem vir juntos'))), + ); + }); + + test('os dois em development leem os caminhos', () { + final config = AppConfig.fromMap({ + 'MQTT_CLIENT_CERT_PATH': '/c/backend.crt', + 'MQTT_CLIENT_KEY_PATH': '/c/backend.key', + }); + expect(config.mqttClientCertificatePath, '/c/backend.crt'); + expect(config.mqttClientKeyPath, '/c/backend.key'); + }); + + test('nenhum em development é aceito', () { + final config = AppConfig.fromMap(const {}); + expect(config.mqttClientCertificatePath, isNull); + expect(config.mqttClientKeyPath, isNull); + }); + + test('nenhum em production com TLS é erro', () { + expect( + () => AppConfig.fromMap(producao()), + throwsA(isA().having( + (e) => e.message, 'message', contains('MQTT_CLIENT_CERT_PATH'))), + ); + }); + + test('nenhum em production sem TLS é aceito (broker de teste do CI)', () { + // Fora de development nenhuma configuração é construível hoje (o único + // SMS_GATEWAY implementado é `log`); o que importa aqui é que o erro, + // se houver, NÃO seja o do certificado de cliente. + expect( + () => AppConfig.fromMap(producao(tls: false)), + throwsA(isA().having( + (e) => e.message, 'message', isNot(contains('MQTT_CLIENT_CERT_PATH')))), + ); + }); + }); + group('defaults', () { test('um ambiente vazio produz a configuração de desenvolvimento', () { final config = AppConfig.fromMap(const {}); diff --git a/docker-compose.yml b/docker-compose.yml index e6535ccc..5ecd27e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -134,6 +134,9 @@ services: MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} MQTT_USE_TLS: "true" MQTT_CA_CERT_PATH: /app/certs/ca.crt + # mTLS: o broker exige certificado de cliente (CN = usuário MQTT). + MQTT_CLIENT_CERT_PATH: /app/certs/backend.crt + MQTT_CLIENT_KEY_PATH: /app/certs/backend.key # Sem esta variável o servidor caía num segredo de desenvolvimento fixo e # público, com o qual qualquer pessoa forjaria um token de ACS. JWT_SECRET: ${JWT_SECRET:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} diff --git a/infra/docker/mosquitto/init.sh b/infra/docker/mosquitto/init.sh index b6d02724..4011c776 100644 --- a/infra/docker/mosquitto/init.sh +++ b/infra/docker/mosquitto/init.sh @@ -81,6 +81,32 @@ EOF rm -f "$ext_file" "$certs_dir/server.csr" fi +client_days=397 + +# Certificado de CLIENTE (mTLS): CN = o usuário MQTT, assinado pela mesma CA do +# servidor, só para autenticação de cliente. Renova quando vence, quando a CA +# mudou (o `verify` falha) ou quando não existe — mesma regra do servidor. +issue_client() { + cn="$1" + if [ -f "$certs_dir/$cn.crt" ] && [ -f "$certs_dir/$cn.key" ] \ + && openssl x509 -in "$certs_dir/$cn.crt" -noout -checkend 86400 >/dev/null 2>&1 \ + && openssl verify -CAfile "$certs_dir/ca.crt" "$certs_dir/$cn.crt" >/dev/null 2>&1; then + return 0 + fi + echo "Gerando certificado de cliente para $cn..." + printf 'basicConstraints=CA:FALSE\nkeyUsage=digitalSignature\nextendedKeyUsage=clientAuth\n' \ + > "$certs_dir/$cn.ext" + openssl req -newkey rsa:2048 -nodes -keyout "$certs_dir/$cn.key" \ + -out "$certs_dir/$cn.csr" -subj "/CN=$cn" + openssl x509 -req -days "$client_days" -in "$certs_dir/$cn.csr" \ + -CA "$certs_dir/ca.crt" -CAkey "$certs_dir/ca.key" -CAcreateserial \ + -extfile "$certs_dir/$cn.ext" -out "$certs_dir/$cn.crt" + rm -f "$certs_dir/$cn.csr" "$certs_dir/$cn.ext" +} + +issue_client backend +issue_client acs-area-12 + # Regrava SEMPRE, em vez de só criar quando o arquivo falta. # # O arquivo é derivado das variáveis de ambiente, e recriá-lo é barato. Com o diff --git a/infra/docker/mosquitto/mosquitto.conf b/infra/docker/mosquitto/mosquitto.conf index 748b5e51..cad2023b 100644 --- a/infra/docker/mosquitto/mosquitto.conf +++ b/infra/docker/mosquitto/mosquitto.conf @@ -5,6 +5,9 @@ listener 8883 0.0.0.0 cafile /mosquitto/certs/ca.crt certfile /mosquitto/certs/server.crt keyfile /mosquitto/certs/server.key +# mTLS: o cliente precisa apresentar certificado assinado pela CA acima. A senha +# e o ACL continuam valendo (sem use_identity_as_username): cert + senha. +require_certificate true allow_anonymous false password_file /mosquitto/config/passwordfile acl_file /mosquitto/config/aclfile diff --git a/scripts/qa/mtls_invariants.sh b/scripts/qa/mtls_invariants.sh new file mode 100755 index 00000000..b749d537 --- /dev/null +++ b/scripts/qa/mtls_invariants.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# O broker MQTT exige certificado de cliente (mTLS) E mantém a senha. +# +# ./scripts/qa/mtls_invariants.sh +# +# Precisa da stack de desenvolvimento no ar (docker compose up) e do .env. +# Quatro casos, cada um uma publicação de `mosquitto_pub` na porta 8883: +# A) senha certa, SEM certificado de cliente -> RECUSADO +# B) senha certa + certificado assinado pela CA do broker -> ACEITO +# C) senha certa + certificado de OUTRA CA -> RECUSADO +# D) certificado certo + senha errada -> RECUSADO +# Nenhum segredo é impresso. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_BACKEND_PASSWORD:?exporte MQTT_BACKEND_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +certs="$repo_root/infra/docker/mosquitto/runtime/certs" +for f in ca.crt backend.crt backend.key; do + [[ -f "$certs/$f" ]] || { echo "erro: $certs/$f não existe (a stack subiu com o init.sh novo?)." >&2; exit 4; } +done + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# Certificado "de fora": CA própria e uma folha dela, com o mesmo CN de um usuário real. +openssl req -x509 -newkey rsa:2048 -nodes -days 2 -keyout "$tmp/fora-ca.key" -out "$tmp/fora-ca.crt" \ + -subj '/CN=ca-de-fora' >/dev/null 2>&1 +openssl req -newkey rsa:2048 -nodes -keyout "$tmp/fora.key" -out "$tmp/fora.csr" -subj '/CN=backend' >/dev/null 2>&1 +printf 'extendedKeyUsage=clientAuth\n' >"$tmp/fora.ext" +openssl x509 -req -days 2 -in "$tmp/fora.csr" -CA "$tmp/fora-ca.crt" -CAkey "$tmp/fora-ca.key" \ + -CAcreateserial -extfile "$tmp/fora.ext" -out "$tmp/fora.crt" >/dev/null 2>&1 +chmod 644 "$tmp"/* + +publicar() { # publicar [--cert X --key Y] (devolve o código de saída) + local senha="$1"; shift + timeout 20 docker run --rm --network host \ + -v "$certs:/c:ro" -v "$tmp:/f:ro" -e MQTT_PW="$senha" eclipse-mosquitto:2 \ + sh -c 'mosquitto_pub -h localhost -p 8883 --cafile /c/ca.crt -u backend -P "$MQTT_PW" \ + -t sinalacs/v1/mtls/probe -m x '"$*" >/dev/null 2>&1 +} + +falhas=0 +esperar() { # esperar + local quer="$1" nome="$2"; shift 2 + if "$@"; then got=aceito; else got=recusado; fi + if [[ "$got" == "$quer" ]]; then echo "ok: $nome ($got)"; else echo "FALHOU: $nome — esperava $quer, foi $got"; falhas=$((falhas + 1)); fi +} + +esperar recusado "A) senha certa, sem certificado de cliente" publicar "$MQTT_BACKEND_PASSWORD" +esperar aceito "B) senha certa + certificado da CA do broker" \ + publicar "$MQTT_BACKEND_PASSWORD" --cert /c/backend.crt --key /c/backend.key +esperar recusado "C) certificado de outra CA" \ + publicar "$MQTT_BACKEND_PASSWORD" --cert /f/fora.crt --key /f/fora.key +esperar recusado "D) certificado certo, senha errada" \ + publicar "senha-errada" --cert /c/backend.crt --key /c/backend.key + +[[ "$falhas" -eq 0 ]] && echo "OK — o broker exige certificado de cliente e mantém a senha" || { echo "$falhas falha(s)"; exit 1; } diff --git a/spec/stack.md b/spec/stack.md index 18e3f9da..e372610b 100644 --- a/spec/stack.md +++ b/spec/stack.md @@ -72,3 +72,5 @@ implementados de ponta a ponta: push segmentado (§3.2, RF14 — provado no emul ACS; adotado geofence único atrelado a uma visita ativa, com serviço em primeiro plano e notificação persistente, para evitar a política mais restritiva de "background location" da Play Store. + +> **Broker com mTLS (2026-10-02):** `require_certificate true`; certificado de cliente por usuário MQTT (`backend`, `acs-area-12`), emitido pela CA de desenvolvimento pelo `mosquitto-init`; senha e ACL mantidos (sem `use_identity_as_username`); prova de recusa em `scripts/qa/mtls_invariants.sh`. Produção exige provisionamento por aparelho (aberto). From 0e8484b79cd66a7a3a1736b89e7ad69ec421735a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 12:57:45 -0400 Subject: [PATCH 40/43] =?UTF-8?q?fix(qa):=20mtls=5Finvariants=20n=C3=A3o?= =?UTF-8?q?=20exp=C3=B5e=20a=20senha=20no=20argv=20do=20docker=20e=20disti?= =?UTF-8?q?ngue=20recusa=20do=20broker=20de=20falha=20de=20infraestrutura?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/qa/mtls_invariants.sh | 37 +++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/scripts/qa/mtls_invariants.sh b/scripts/qa/mtls_invariants.sh index b749d537..53b44cd4 100755 --- a/scripts/qa/mtls_invariants.sh +++ b/scripts/qa/mtls_invariants.sh @@ -35,18 +35,43 @@ openssl x509 -req -days 2 -in "$tmp/fora.csr" -CA "$tmp/fora-ca.crt" -CAkey "$tm -CAcreateserial -extfile "$tmp/fora.ext" -out "$tmp/fora.crt" >/dev/null 2>&1 chmod 644 "$tmp"/* -publicar() { # publicar [--cert X --key Y] (devolve o código de saída) +# Pré-condição: o broker responde a um handshake TLS. Sem isso, "recusado" nos +# casos abaixo seria vácuo (broker parado, porta errada). +if ! openssl s_client -connect localhost:8883 -CAfile "$certs/ca.crt" /dev/null 2>&1; then + echo "erro: o broker não respondeu ao handshake TLS em localhost:8883 (docker compose up -d?)." >&2 + exit 4 +fi + +# publicar [--cert X --key Y] +# Códigos: 0 = aceito; 1 = recusado pelo broker (mosquitto_pub rodou e a saída +# traz a marca de recusa: conexão perdida no handshake TLS ou CONNACK negado); +# 2 = erro de infraestrutura (docker, timeout, imagem) — NUNCA conta como recusa. +# A senha vai pelo ambiente do processo do docker (`-e MQTT_PW`, só o nome), não +# pelo argv; a saída do mosquitto_pub é guardada, nunca impressa. +publicar() { local senha="$1"; shift - timeout 20 docker run --rm --network host \ - -v "$certs:/c:ro" -v "$tmp:/f:ro" -e MQTT_PW="$senha" eclipse-mosquitto:2 \ + local saida rc=0 + saida="$(MQTT_PW="$senha" timeout 20 docker run --rm --network host \ + -v "$certs:/c:ro" -v "$tmp:/f:ro" -e MQTT_PW eclipse-mosquitto:2 \ sh -c 'mosquitto_pub -h localhost -p 8883 --cafile /c/ca.crt -u backend -P "$MQTT_PW" \ - -t sinalacs/v1/mtls/probe -m x '"$*" >/dev/null 2>&1 + -t sinalacs/v1/mtls/probe -m x '"$*" 2>&1)" || rc=$? + [[ "$rc" -eq 0 ]] && return 0 + if [[ "$rc" -eq 5 || "$rc" -eq 7 ]] \ + && grep -qiE 'connection was lost|connection was refused|connection refused|not authori[sz]ed' <<<"$saida"; then + return 1 + fi + return 2 } falhas=0 esperar() { # esperar - local quer="$1" nome="$2"; shift 2 - if "$@"; then got=aceito; else got=recusado; fi + local quer="$1" nome="$2" rc=0; shift 2 + "$@" || rc=$? + case "$rc" in + 0) got=aceito ;; + 1) got=recusado ;; + *) echo "erro: $nome — falha de infraestrutura (código $rc), não é recusa do broker." >&2; exit 4 ;; + esac if [[ "$got" == "$quer" ]]; then echo "ok: $nome ($got)"; else echo "FALHOU: $nome — esperava $quer, foi $got"; falhas=$((falhas + 1)); fi } From 05fcd1cae2be7cfaaba5d81e193e888b95c3b36f Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 13:10:50 -0400 Subject: [PATCH 41/43] feat(acs): app apresenta certificado de cliente ao broker (mTLS); release barra a chave de desenvolvimento --- PROGRESS.md | 1 + apps/CLAUDE.md | 2 + apps/acs/android/app/build.gradle.kts | 15 ++++ apps/acs/lib/core/network/backend_config.dart | 6 ++ apps/acs/lib/core/services/alert_feed.dart | 23 +++++- .../lib/core/services/mqtt_secure_client.dart | 29 ++++++- apps/acs/test/alert_feed_test.dart | 13 ++++ apps/acs/test/mqtt_secure_client_test.dart | 76 +++++++++++++++++++ apps/acs/tool/live_check.dart | 5 ++ scripts/dev/sync_dev_ca.sh | 33 ++++++++ scripts/qa/acs_release_signing.sh | 31 ++++++-- 11 files changed, 226 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f117f808..c14a7443 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -571,6 +571,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **Fora:** refresh token, iOS (`apps/acs/ios/` não existe). - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. - **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa digitar matrícula + senha + código de novo quando a sessão de 15 minutos vence, porque ainda não existe refresh token** (o código TOTP é de uso único e não dá para renovar em silêncio): `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória, falha de forma não recuperável e `AcsBackend.onSessionExpired` leva o painel de volta ao login com o aviso; as visitas pendentes ficam na fila (disco) e a sincronização que falhou as mantém pendentes. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo seguinte). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). +- **mTLS do broker (2026-10-02).** Entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de desenvolvimento `assets/certs/acs_client.*`, copiado por `sync_dev_ca.sh`, gitignorado), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); o release com a chave de dev é barrado pelo Gradle (`-Psinalacs.allowDevClientKey=true` só para teste local). - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. ## Migração para Serverpod diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index c06ba71e..7436628d 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -30,6 +30,8 @@ Both apps consume the generated `sinalacs_client` by path dependency and talk to The RPC trusts a development CA that is **separate** from the broker's: `scripts/dev/sync_dev_ca.sh` copies two of them into `apps/*/assets/certs/` — `dev_ca.crt`, the Mosquitto CA that signs the broker's certificate for MQTT on 8883, and `dev_rpc_ca.crt`, which signs the certificate Traefik presents on :443. They are deliberately not unified (see [../docs/README.md](../docs/README.md)). Both apps keep hostname verification on — the TLS stack's, which is never disabled: no path sets `badCertificateCallback => true`, so an untrusted RPC certificate must fail, never silently degrade to accepting any certificate. On top of it, `requireSecureHost` validates the **scheme**, not the hostname (it parses the URI and requires `https`): a host that is not `https://` is rejected rather than used in cleartext. +**mTLS to the broker (dev stack).** The broker requires a client certificate (password and ACL stay). `sync_dev_ca.sh` also copies `runtime/certs/acs-area-12.{crt,key}` (after `openssl verify` against the broker CA) to the gitignored assets `apps/acs/assets/certs/acs_client.{crt,key}`; `AlertFeed` loads them (`BackendConfig.mqttClientCertAsset`/`mqttClientKeyAsset`; absent -> `AlertFeedFailureKind.missingClientCertificate`, non-transient) and `buildMqttSecurityContext` presents them while still trusting only the project CA. `tool/live_check.dart` reads the same pair from `runtime/certs`. The private key inside an asset is dev-only: the Gradle release guard fails the build while `assets/certs/acs_client.key` exists, unless `-Psinalacs.allowDevClientKey=true` (local test release; `scripts/qa/acs_release_signing.sh`, scenario 4). Per-device provisioning in production is still open. + The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dart` sends the matrícula and the senha the person typed to `auth.loginInstitutional` and only opens the panel once the server issues a token. The credential lives **in memory only** — `BackendClient._credentials`, never on disk — which is what lets the silent renewal reauthenticate when the 15-minute token expires. `developmentLogin` survives as a tool-only method, for `tool/live_check.dart` and `integration_test/` against a stack with `ENABLE_DEV_LOGIN=true`; it stores no credential, so a session started that way has nothing to renew from. The durable fix, the rotating refresh token that LGPD-RT06 asks for, is deferred along with MFA/TOTP — the gaps are registered in [PROGRESS.md](../PROGRESS.md). The patient login is passwordless now (RF01), and no longer uses `developmentLogin` either: the app sends the CPF and the birth date to `auth.requestOtp`, then the 6-digit code to `auth.verifyOtp`, and only that second call issues the session. **There is no silent renewal on this side, on purpose** — an OTP code cannot be replayed the way the ACS's password can, so a 15-minute token would mean a new SMS every 15 minutes. The session therefore lives **1 hour** (`AuthEndpoint.patientSessionLifetime`, applying LGPD-RT06), the single deliberate asymmetry with the ACS: when it expires the app does not renew, it shows "Sua sessão expirou. Entre novamente com o código de acesso." with a button back to the login (`reenter_button`), because that is the only way back in. The asymmetry is written down in `spec/lgpd_design.md` so the next reader does not read it as an oversight. Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app asks `patients.hasAcceptedCurrentTerms` (a `bool` — not the whole "Meus dados" panel, which would write a read-audit row on every login) with a 3 s timeout and, when the answer is `false`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — "Agora não" and a failed or slow `hasAcceptedCurrentTerms` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it. After login and onboarding, and when "Avisos da equipe" is granted, the app silently calls `devices.registerPushToken` with the token from `PushTokenSource` (`core/push/push_token_source.dart`); the token comes from `pushTokenSourceProvider` (the only `flutter_riverpod` use in the patient app; `SinalAcsApp(pushTokens:)` overrides it in tests, and without a `ProviderScope` the app falls back to `NoPushTokenSource`), whose real implementation `NativePushTokenSource` asks the `sinalacs/push_token` method channel — the Android side exists (`MainActivity.kt` with `firebase-messaging`; the Google Services plugin is applied **only** when the git-ignored `android/app/google-services.json` exists, so CI and other devs still build and the app degrades to no push), while **the iOS/Swift side does not exist**. The manifest turns FCM auto-init **off** (`firebase_messaging_auto_init_enabled=false`, guarded by `test/android_push_manifest_test.dart`): otherwise the device would contact Google on every app open, before login and without consent; the explicit `getToken()` still works. `registerPushDevice` asks the provider for the token only after `patients.hasGrantedConsent(segmentedPush)` answers `true` (a `bool`: no dossier, no read-audit row; 3 s ceiling, fail closed, late answer discarded; skipped when the source is `NoPushTokenSource`). Onboarding (only when the box is ticked) and the "Meus dados" switch pass `consentKnownGranted: true` because they just wrote the decision. `scripts/qa/push_e2e.sh` proves the real delivery on `emulator-5554`; it needs the FCM service-account key and is not run in CI, and the two push integration tests only run with `--dart-define=PUSH_E2E=1`, so `e2e.sh --full` stays green without credentials. A refusal or failure never blocks the home or the emergency alert. The home also fetches `patients.termsChangeNotice` once (3 s ceiling, failure or a shell without `BackendScope` = no card) and shows a dismissible `TermsChangeNoticeCard` — never a gate, and never on the urgency tab. When the app embeds the text of exactly the version the server announced (`upcomingLegalDocuments`, injected via `UpcomingDocumentsScope`), the card also offers "Ler o texto novo" (`LegalDocumentsScreen(upcoming:)`); otherwise only "Ler os termos atuais". `PushTokenScope.maybeOf` falls back to no push instead of throwing. In the ACS app, "Avisos à comunidade" (`NoticesScreen`) calls `AcsBackend.sendNotice` → `notices.sendSegmented`. diff --git a/apps/acs/android/app/build.gradle.kts b/apps/acs/android/app/build.gradle.kts index 438e7b39..d66a6af8 100644 --- a/apps/acs/android/app/build.gradle.kts +++ b/apps/acs/android/app/build.gradle.kts @@ -175,4 +175,19 @@ gradle.taskGraph.whenReady { """.trimMargin() ) } + + // A chave privada de CLIENTE do broker de desenvolvimento (assets/certs/acs_client.key) + // não pode ir dentro de um APK de release: qualquer pessoa com o APK a extrairia. + val chaveDeDev = rootProject.file("../assets/certs/acs_client.key") + if (buildaRelease && chaveDeDev.exists() && !project.hasProperty("sinalacs.allowDevClientKey")) { + throw GradleException( + """ + |O release levaria a chave privada de desenvolvimento do broker (assets/certs/acs_client.key). + | + |Remova o arquivo antes de gerar o release (em produção o certificado de cliente é + |provisionado por aparelho — ainda não implementado). Para um release de TESTE LOCAL, + |acrescente -Psinalacs.allowDevClientKey=true. + """.trimMargin() + ) + } } diff --git a/apps/acs/lib/core/network/backend_config.dart b/apps/acs/lib/core/network/backend_config.dart index 9392d13e..ddf40b42 100644 --- a/apps/acs/lib/core/network/backend_config.dart +++ b/apps/acs/lib/core/network/backend_config.dart @@ -75,6 +75,12 @@ class BackendConfig { /// mosquitto-init e não é versionada. static const String mqttCaAsset = 'assets/certs/dev_ca.crt'; + /// Certificado e chave de CLIENTE para o mTLS do broker (spec/stack.md). + /// **Só desenvolvimento**: a chave privada num asset serve para a stack local; em + /// produção o certificado é provisionado por aparelho (aberto). + static const mqttClientCertAsset = 'assets/certs/acs_client.crt'; + static const mqttClientKeyAsset = 'assets/certs/acs_client.key'; + /// CA que assina o certificado do Traefik em :443 (RNF04). /// /// Não confundir com [mqttCaAsset], que é a CA do broker. São duas, e o diff --git a/apps/acs/lib/core/services/alert_feed.dart b/apps/acs/lib/core/services/alert_feed.dart index b3ae6c62..a42cd081 100644 --- a/apps/acs/lib/core/services/alert_feed.dart +++ b/apps/acs/lib/core/services/alert_feed.dart @@ -33,6 +33,10 @@ enum AlertFeedFailureKind { /// Falta o certificado da CA nos assets (`scripts/dev/sync_dev_ca.sh`). missingCaAsset, + /// Falta o certificado de cliente do ACS nos assets + /// (`scripts/dev/sync_dev_ca.sh`): sem ele o broker (mTLS) recusa. + missingClientCertificate, + /// O broker respondeu e recusou (credencial, identificador, indisponível). refused, @@ -64,7 +68,7 @@ class AlertFeedFailure implements Exception { /// Se vale a pena tentar de novo sozinho, sem intervenção humana. /// - /// `missingPassword`/`missingCaAsset` exigem recompilar; `refused` herda o + /// `missingPassword`/`missingCaAsset`/`missingClientCertificate` exigem recompilar; `refused` herda o /// veredito do CONNACK; `unreachable` é sempre transitória — falta de rede /// é o caso comum de um ACS em campo. final bool transient; @@ -124,6 +128,21 @@ class MqttAlertFeed implements AlertFeed { ); } + // Certificado e chave de CLIENTE (mTLS), também assets gitignorados. + final ByteData clientCertificate; + final ByteData clientPrivateKey; + try { + clientCertificate = await rootBundle.load(BackendConfig.mqttClientCertAsset); + clientPrivateKey = await rootBundle.load(BackendConfig.mqttClientKeyAsset); + } catch (error) { + throw AlertFeedFailure( + AlertFeedFailureKind.missingClientCertificate, + title: 'Falta o certificado de cliente da central neste aplicativo.', + detail: 'Sem ele o broker recusa a conexão.', + cause: error, + ); + } + final client = MqttSecureClient( config: SecureMqttConfig( brokerHost: BackendConfig.mqttHost, @@ -136,6 +155,8 @@ class MqttAlertFeed implements AlertFeed { username: BackendConfig.mqttUsername, password: BackendConfig.mqttPassword, caCertificate: caCertificate.buffer.asUint8List(), + clientCertificate: clientCertificate.buffer.asUint8List(), + clientPrivateKey: clientPrivateKey.buffer.asUint8List(), ), ); diff --git a/apps/acs/lib/core/services/mqtt_secure_client.dart b/apps/acs/lib/core/services/mqtt_secure_client.dart index b0734eec..ad9a44b2 100644 --- a/apps/acs/lib/core/services/mqtt_secure_client.dart +++ b/apps/acs/lib/core/services/mqtt_secure_client.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:mqtt_client/mqtt_client.dart'; @@ -68,6 +69,24 @@ String? mqttRefusalReason(MqttConnectReturnCode? code) => switch (code) { bool mqttRefusalIsTransient(MqttConnectReturnCode? code) => code == MqttConnectReturnCode.brokerUnavailable; +/// Contexto TLS do cliente MQTT: confia **só** na CA do projeto e, se houver +/// certificado de cliente, o apresenta (mTLS). Hostname segue verificado. +SecurityContext buildMqttSecurityContext(SecureMqttConfig config) { + final contexto = SecurityContext(withTrustedRoots: false); + final ca = config.caCertificate; + if (ca != null) { + contexto.setTrustedCertificatesBytes(ca); + } + final cert = config.clientCertificate; + final chave = config.clientPrivateKey; + if (cert != null && chave != null) { + contexto + ..useCertificateChainBytes(cert) + ..usePrivateKeyBytes(chave); + } + return contexto; +} + class SecureMqttConfig { const SecureMqttConfig({ required this.brokerHost, @@ -79,6 +98,8 @@ class SecureMqttConfig { this.username, this.password, this.caCertificate, + this.clientCertificate, + this.clientPrivateKey, }); final String brokerHost; @@ -102,6 +123,11 @@ class SecureMqttConfig { /// dentro do APK, onde não existe caminho no sistema de arquivos. final List? caCertificate; + /// Certificado e chave privada de CLIENTE (PEM) para o mTLS do broker. Só + /// valem juntos; sem os dois, o cliente não apresenta certificado. + final Uint8List? clientCertificate; + final Uint8List? clientPrivateKey; + String get connectionUri { if (useWebSocket) { final scheme = useTls ? 'wss' : 'ws'; @@ -287,8 +313,7 @@ class MqttSecureClient { // é o que o certificado com subjectAltName (infra/docker/mosquitto/init.sh) // passou a satisfazer. Não desligue com `onBadCertificate`: seria abrir o // caminho de spoofing do tópico de alertas. - client.securityContext = SecurityContext(withTrustedRoots: false) - ..setTrustedCertificatesBytes(caCertificate); + client.securityContext = buildMqttSecurityContext(config); } // O cliente é assumido ANTES de conectar. Com `autoReconnect`, uma falha de diff --git a/apps/acs/test/alert_feed_test.dart b/apps/acs/test/alert_feed_test.dart index f331b064..57025f63 100644 --- a/apps/acs/test/alert_feed_test.dart +++ b/apps/acs/test/alert_feed_test.dart @@ -37,4 +37,17 @@ void main() { ); }); }); + + group('certificado de cliente (mTLS)', () { + test('falta do certificado de cliente é uma falha própria e não transitória', () { + expect(AlertFeedFailureKind.values, + contains(AlertFeedFailureKind.missingClientCertificate)); + const falha = AlertFeedFailure( + AlertFeedFailureKind.missingClientCertificate, + title: 'Falta o certificado de cliente da central neste aplicativo.', + detail: 'Sem ele o broker recusa a conexão.', + ); + expect(falha.transient, isFalse); + }); + }); } diff --git a/apps/acs/test/mqtt_secure_client_test.dart b/apps/acs/test/mqtt_secure_client_test.dart index e18fd87b..87519a5e 100644 --- a/apps/acs/test/mqtt_secure_client_test.dart +++ b/apps/acs/test/mqtt_secure_client_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:sinalacs_acs/core/services/mqtt_secure_client.dart'; @@ -159,4 +162,77 @@ void main() { expect(mqttRefusalIsTransient(null), isFalse); }); }); + + group('mTLS: certificado de cliente', () { + test('a config guarda certificado e chave de cliente quando informados', () { + final cert = Uint8List.fromList([1, 2, 3]); + final chave = Uint8List.fromList([4, 5, 6]); + final config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + clientCertificate: cert, + clientPrivateKey: chave, + ); + expect(config.clientCertificate, cert); + expect(config.clientPrivateKey, chave); + }); + + test('sem certificado de cliente os dois campos ficam nulos', () { + const config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + ); + expect(config.clientCertificate, isNull); + expect(config.clientPrivateKey, isNull); + }); + + final temOpenssl = () { + try { + return Process.runSync('openssl', ['version']).exitCode == 0; + } on ProcessException { + return false; + } + }(); + + test('buildMqttSecurityContext monta com um par PEM válido', () { + final dir = Directory.systemTemp.createTempSync('mqtt_ctx_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final r = Process.runSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=teste', '-keyout', '${dir.path}/k.pem', + '-out', '${dir.path}/c.pem', + ]); + expect(r.exitCode, 0); + final cert = File('${dir.path}/c.pem').readAsBytesSync(); + final chave = File('${dir.path}/k.pem').readAsBytesSync(); + final config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + caCertificate: cert, + clientCertificate: cert, + clientPrivateKey: chave, + ); + expect(() => buildMqttSecurityContext(config), returnsNormally); + }, skip: temOpenssl ? false : 'openssl ausente'); + + test('buildMqttSecurityContext recusa bytes que não são PEM', () { + final lixo = Uint8List.fromList(List.filled(32, 7)); + final config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + caCertificate: lixo, + clientCertificate: lixo, + clientPrivateKey: lixo, + ); + expect(() => buildMqttSecurityContext(config), throwsA(isA())); + }); + }); } diff --git a/apps/acs/tool/live_check.dart b/apps/acs/tool/live_check.dart index 70447387..579a431c 100644 --- a/apps/acs/tool/live_check.dart +++ b/apps/acs/tool/live_check.dart @@ -109,6 +109,8 @@ Future main(List args) async { // CA do BROKER (MQTT). A do RPC é a de [_devRpcCaBytes], e continuam sendo // duas: apontar esta para a CA do Traefik derruba a leg do MQTT. final caPath = _arg(args, 'ca', '../../infra/docker/mosquitto/runtime/certs/ca.crt'); + final clientCertPath = _arg(args, 'client-cert', '../../infra/docker/mosquitto/runtime/certs/acs-area-12.crt'); + final clientKeyPath = _arg(args, 'client-key', '../../infra/docker/mosquitto/runtime/certs/acs-area-12.key'); final emitMetrics = args.contains('--emit-metrics'); final visitCount = _intArg(args, 'visit-count', 1).clamp(1, 1000); @@ -201,6 +203,9 @@ Future main(List args) async { username: _arg(args, 'mqtt-user', BackendConfig.mqttUsername), password: mqttPassword, caCertificate: File(caPath).readAsBytesSync(), + // mTLS: o broker exige certificado de cliente (Task 7). + clientCertificate: File(clientCertPath).readAsBytesSync(), + clientPrivateKey: File(clientKeyPath).readAsBytesSync(), ), ); diff --git a/scripts/dev/sync_dev_ca.sh b/scripts/dev/sync_dev_ca.sh index a893137e..740dec18 100755 --- a/scripts/dev/sync_dev_ca.sh +++ b/scripts/dev/sync_dev_ca.sh @@ -124,6 +124,27 @@ for par in "${pares[@]}"; do "$label" done +# mTLS do broker: o ACS também apresenta um certificado de CLIENTE +# (acs-area-12, emitido pelo init.sh do Mosquitto). Mesma prova da CA: a folha +# tem de ser assinada pela CA do broker que está em runtime/, senão o handshake +# falha no app sem nada vermelho em lugar nenhum. **Só desenvolvimento**: a +# chave privada vira asset gitignorado (o guard do Gradle barra um release com +# ela dentro). +mqtt_certs="$repo_root/infra/docker/mosquitto/runtime/certs" +acs_assets="$repo_root/apps/acs/assets/certs" +for f in acs-area-12.crt acs-area-12.key; do + if [[ ! -f "$mqtt_certs/$f" ]]; then + echo "erro: $mqtt_certs/$f não existe — nada foi copiado." >&2 + echo "Suba a stack primeiro (docker compose up) para que o certificado de cliente seja gerado." >&2 + exit 1 + fi +done +if ! verify_err="$(openssl verify -CAfile "$mqtt_certs/ca.crt" "$mqtt_certs/acs-area-12.crt" 2>&1)"; then + echo "erro: a CA do broker não assina o certificado de cliente $mqtt_certs/acs-area-12.crt — nada foi copiado." >&2 + echo "$verify_err" >&2 + exit 1 +fi + # Fase 2 — preparar os quatro (nada visível ainda para quem lê o asset). for app in acs patient; do for par in "${pares[@]}"; do @@ -135,6 +156,13 @@ for app in acs patient; do done done +# Cliente do ACS: temporário + mv, igual às CAs. A chave nasce 600 (umask) e +# o chmod é explícito para não depender dele. +mkdir -p "$acs_assets" +(umask 077; cp "$mqtt_certs/acs-area-12.key" "$acs_assets/acs_client.key.tmp") +chmod 600 "$acs_assets/acs_client.key.tmp" +cp "$mqtt_certs/acs-area-12.crt" "$acs_assets/acs_client.crt.tmp" + # Fase 3 — publicar os quatro. for app in acs patient; do for par in "${pares[@]}"; do @@ -144,3 +172,8 @@ for app in acs patient; do "$label" done done + +mv "$acs_assets/acs_client.crt.tmp" "$acs_assets/acs_client.crt" +mv "$acs_assets/acs_client.key.tmp" "$acs_assets/acs_client.key" +echo "Certificado de cliente do ACS (mTLS) copiado para $acs_assets/acs_client.{crt,key}" +openssl x509 -in "$acs_assets/acs_client.crt" -noout -subject -dates diff --git a/scripts/qa/acs_release_signing.sh b/scripts/qa/acs_release_signing.sh index 0aadb1c8..67a91f1a 100755 --- a/scripts/qa/acs_release_signing.sh +++ b/scripts/qa/acs_release_signing.sh @@ -4,11 +4,13 @@ # # ./scripts/qa/acs_release_signing.sh # -# Três cenários, cada um uma build de release (~1 min): +# Quatro cenários, cada um uma build de release (~1 min): # 1. sem chave de release e sem a licença de debug -> a build FALHA, dizendo por quê; # 2. com -Psinalacs.allowDebugSigning=true -> assina com "Android Debug"; # 3. com um keystore descartável (criado aqui, em /tmp) por variáveis de ambiente -# -> assina com o CN do keystore. +# -> assina com o CN do keystore; +# 4. com a chave privada de CLIENTE de desenvolvimento em assets/certs/ e sem a licença +# -Psinalacs.allowDevClientKey=true -> a build FALHA (a chave iria no APK). # Nenhum segredo real é tocado: o keystore do cenário 3 nasce e morre neste script. set -euo pipefail @@ -16,6 +18,9 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" if [[ -f .env ]]; then set -a; source .env; set +a; fi +# Hermético: o .env ou o ambiente do chamador não pode injetar uma chave de +# release nos cenários 1, 2 e 4. O cenário 3 as define por conta própria. +unset SINALACS_KEYSTORE_PATH SINALACS_KEYSTORE_PASSWORD SINALACS_KEY_ALIAS SINALACS_KEY_PASSWORD : "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" if [[ -f apps/acs/android/key.properties ]]; then @@ -26,6 +31,15 @@ apksigner="$(ls "$HOME"/Android/Sdk/build-tools/*/apksigner | tail -1)" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT apk=apps/acs/build/app/outputs/flutter-apk/app-release.apk +# O guard da chave de desenvolvimento (cenário 4) olha este arquivo. Se não existir +# (sync_dev_ca.sh não rodou), cria um marcador vazio e o remove no trap; se existir, +# é a chave real de dev e fica como está. +chave_dev=apps/acs/assets/certs/acs_client.key +chave_dev_criada=0 +if [[ ! -e "$chave_dev" ]]; then + mkdir -p "$(dirname "$chave_dev")"; : > "$chave_dev"; chave_dev_criada=1 +fi +trap 'rm -rf "$tmp"; [[ "$chave_dev_criada" -eq 1 ]] && rm -f "$chave_dev"' EXIT define="--dart-define=SINALACS_MQTT_PASSWORD=$MQTT_ACS_PASSWORD" # O Gradle lê as variáveis SINALACS_KEYSTORE_* do ambiente; nos cenários 1 e 2 elas não existem. @@ -39,7 +53,7 @@ fi grep -q "assinatura de release" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } echo "== cenário 2: -Psinalacs.allowDebugSigning=true ==" -construir -Psinalacs.allowDebugSigning=true >/dev/null +construir -Psinalacs.allowDebugSigning=true -Psinalacs.allowDevClientKey=true >/dev/null "$apksigner" verify --print-certs "$apk" | grep -q "CN=Android Debug" \ || { echo "erro: o cenário 2 não assinou com a chave de debug." >&2; exit 1; } @@ -47,8 +61,15 @@ echo "== cenário 3: keystore descartável por variáveis de ambiente ==" keytool -genkeypair -keystore "$tmp/teste.jks" -storepass senhateste -keypass senhateste \ -alias teste -keyalg RSA -keysize 2048 -validity 2 -dname "CN=SinalACS Teste" >/dev/null 2>&1 SINALACS_KEYSTORE_PATH="$tmp/teste.jks" SINALACS_KEYSTORE_PASSWORD=senhateste \ -SINALACS_KEY_ALIAS=teste SINALACS_KEY_PASSWORD=senhateste construir >/dev/null +SINALACS_KEY_ALIAS=teste SINALACS_KEY_PASSWORD=senhateste construir -Psinalacs.allowDevClientKey=true >/dev/null "$apksigner" verify --print-certs "$apk" | grep -q "CN=SinalACS Teste" \ || { echo "erro: o cenário 3 não assinou com o keystore informado." >&2; exit 1; } -echo "OK — release exige chave própria; debug só com licença explícita" +echo "== cenário 4: chave de cliente de desenvolvimento no release ==" +if saida="$(construir -Psinalacs.allowDebugSigning=true 2>&1)"; then + echo "erro: o release passou levando a chave de desenvolvimento." >&2 + exit 1 +fi +grep -q "chave privada de desenvolvimento" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } + +echo "OK — release exige chave própria; debug só com licença explícita; a chave de cliente de dev não vai no APK" From 7055ea375520a60dfa5bbcfc8d5b29dbb4d4bf85 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 13:24:48 -0400 Subject: [PATCH 42/43] =?UTF-8?q?docs:=20registra=20o=20fechamento=20das?= =?UTF-8?q?=20pend=C3=AAncias=20do=20ACS=20(FLAG=5FSECURE,=20assinatura,?= =?UTF-8?q?=20UBS,=20RF08,=20MFA,=20mTLS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- PROGRESS.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 122264cd..44c9a864 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL). Still missing: MFA and refresh token (deliberately deferred — see `PROGRESS.md`), mTLS in production (the broker requires a client certificate in dev, `scripts/qa/mtls_invariants.sh`; per-device provisioning is still missing), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). +SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), `FLAG_SECURE` on the ACS window, release builds signed with an own key (debug signing needs an explicit flag) and the UBS call button. Still missing: refresh token (deliberately deferred — with MFA on, the ACS session of 15 min cannot be renewed silently; see `PROGRESS.md`, section Pendências do ACS), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), MFA reset by the coordinator (manual today), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). Two core flows: - **Paciente (patient)**: passwordless auth (OTP), urgency alert, structured triage, request status tracking, "Meus Dados" (LGPD). diff --git a/PROGRESS.md b/PROGRESS.md index c14a7443..b30478dd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -567,13 +567,57 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. - **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). - **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). -- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, refresh token, iOS, botão da UBS. +- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, refresh token, iOS, botão da UBS. **(fechado depois — ver a seção Pendências do ACS (2026-10-02): RF08, `FLAG_SECURE`, assinatura de release e botão da UBS estão fechados; refresh token e iOS seguem abertos.)** - **Fora:** refresh token, iOS (`apps/acs/ios/` não existe). - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. - **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa digitar matrícula + senha + código de novo quando a sessão de 15 minutos vence, porque ainda não existe refresh token** (o código TOTP é de uso único e não dá para renovar em silêncio): `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória, falha de forma não recuperável e `AcsBackend.onSessionExpired` leva o painel de volta ao login com o aviso; as visitas pendentes ficam na fila (disco) e a sincronização que falhou as mantém pendentes. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo seguinte). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). - **mTLS do broker (2026-10-02).** Entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de desenvolvimento `assets/certs/acs_client.*`, copiado por `sync_dev_ca.sh`, gitignorado), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); o release com a chave de dev é barrado pelo Gradle (`-Psinalacs.allowDevClientKey=true` só para teste local). - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. +## Pendências do ACS (2026-10-02) + +Plano: `docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md`. Fecha as pendências deixadas pela revisão de 2026-10-02 (linha "Continua aberto" acima). Um commit de fechamento por tarefa; provas medidas no `emulator-5554`. + +- **T1 — `FLAG_SECURE` (`0930522`).** Janela inteira do ACS protegida. Prova: `scripts/qa/acs_secure_window.sh` (`dumpsys window`) e teste de fonte. Aberto: capturas de tela do ACS (`docs/telas-acs.md`) agora saem pretas e não há procedimento para regenerá-las. +- **T2 — assinatura de release (`e37bf5d`).** `assembleRelease` falha sem chave própria (`key.properties` ou `SINALACS_KEYSTORE_*`); debug só com `-Psinalacs.allowDebugSigning=true`. Prova: `scripts/qa/acs_release_signing.sh`. Aberto: **o keystore de release real e o segredo dele na CI não existem**. +- **T3 — botão da UBS (`b28f9be`, `7160e4c`).** `ubs.contactPhone` (nullable) entregue por `ubs.myContact`; sem telefone o botão avisa e não liga; a consulta da UBS não trava o botão do SAMU. Prova: testes do app e do servidor, `full_journey_e2e`. Aberto: o cadastro do telefone pelo backoffice (mock); vale a seed. +- **T4 — RF08 persistido (`43c58fa`).** Cache da microárea na base SQLCipher (schema v6), por dono, 72 h, só em falha recuperável de rede; decisão de LGPD em `spec/lgpd_design.md` §5.11. Prova: testes e `full_journey_e2e` (serve sem rede; o arquivo do banco não contém o nome). Aberto: ver Minors adiados. +- **T5 — MFA TOTP, backend (`d6718dd`, `dc517f7`).** RFC 6238, segredo cifrado AES-256-GCM, replay e corrida de ativação barrados (5 logins simultâneos, 1 sessão), `REQUIRE_ACS_MFA` exigida fora de `development`. Prova: suíte do backend. Aberto: sem lockout progressivo além do limite de 5 tentativas/15 min. +- **T6 — MFA no app (`abb8438`, `69c19d4`).** Campo do código no login, tela de ativação com QR, expiração de sessão sem renovação silenciosa. Prova: `mfa_login_test`, `text_scale_test` e o último teste de `full_journey_e2e` no emulador. Aberto: ver a consequência da sessão de 15 min abaixo. +- **T7 — mTLS, broker e backend (`38beea2`, `0e8484b`).** `require_certificate true`, senha e ACL mantidas, backend apresenta certificado. Prova: `scripts/qa/mtls_invariants.sh` (recusa sem certificado e de outra CA, aceite com ambos; distingue recusa de falha de infraestrutura). Aberto: só na stack local. +- **T8 — mTLS, app (`05fcd1c`).** O ACS apresenta o certificado de cliente (asset de desenvolvimento `assets/certs/acs_client.*`); o release com essa chave dentro falha a build. Prova: `e2e.sh --emulator` e `full_journey_e2e` contra o broker com mTLS. Aberto: **provisionamento de certificado por aparelho em produção** (CSR no cadastro, chave no Keystore). +- **T9 — registro e barra final.** Este texto, o `CLAUDE.md` da raiz e a barra completa (resultados no relatório da tarefa). + +### Decisões do plano (D1–D8) + +- **D1:** `FLAG_SECURE` na janela inteira do ACS, não só na lista (a lista aparece em Área, Visita e no seletor); `screencap` e gravação saem pretos. +- **D2:** chave de release de `apps/acs/android/key.properties` ou `SINALACS_KEYSTORE_*`; sem chave a build falha; debug só com licença explícita; o plano não cria keystore nem segredo de CI. +- **D3:** telefone da UBS em `ubs.contactPhone` nullable, via `ubs.myContact`; sem número o botão avisa; o cadastro é do backoffice. +- **D4:** o cache de RF08 guarda só o que a tela de visita já mostra, na base SQLCipher, 72 h, chave `userId|microAreaId`, apagado ao mudar o dono ou vencer, só no lugar de falha recuperável de rede. +- **D5:** MFA = TOTP RFC 6238 (SHA-1, 6 dígitos, 30 s, janela ±1), segredo cifrado com a chave dos dados clínicos, ativação sem token (matrícula + senha + código), `REQUIRE_ACS_MFA` falsa em `development` e verdadeira fora. +- **D6:** refresh token e redefinição de MFA por coordenador ficam fora. +- **D7:** mTLS mantém a senha MQTT (`require_certificate true` sem `use_identity_as_username`): certificado e senha. +- **D8:** o certificado de cliente do ACS vem de asset de desenvolvimento; a chave privada no APK serve só ao dev e o release com ela falha; o mTLS fica entregue na stack local e aberto para produção. + +### Consequência de produto a decidir + +Com MFA ligada e sem refresh token, a sessão do ACS (15 min) não pode ser renovada em silêncio (o código TOTP é de uso único): **o ACS digita matrícula + senha + código a cada 15 minutos**. As visitas pendentes ficam na fila. A decisão é do usuário: implementar refresh token ou alongar a sessão do ACS. + +### Continua aberto + +Refresh token; redefinição de MFA por coordenador (hoje manual: zerar as quatro colunas `totp*` do ACS); provisionamento de certificado por aparelho em produção; keystore de release como segredo na CI; cadastro do telefone da UBS pelo backoffice; iOS. + +### Minors adiados + +- T1: o script de `FLAG_SECURE` usa `awk` com `exit` sobre o pipe do `dumpsys` (risco de SIGPIPE com `pipefail`) e `sleep 6` fixo; `secure_window_test` só confere string no fonte; falta documentar como regenerar as capturas do ACS. +- T2: `acs_release_signing.sh` não é hermético contra `SINALACS_KEYSTORE_*` já exportadas; `hasProperty` vale mesmo com `=false`; a mensagem não diz qual das quatro propriedades falta; `apksigner` escolhido por ordem lexical. +- T3: o store do ORM faz duas consultas; "UBS não encontrada" vira `AlertPermissionException`; `capture-acs.sh` cita o rótulo antigo da UBS. +- T4: dois comentários ainda citam §5.7 em vez de §5.11 (`micro_area_cache_store.dart`, `micro_area_directory.dart`); relógio do aparelho voltado serve cache de mais de 72 h; o cache do dono anterior só some no próximo load; o guard do `main.dart` é por string. +- T5: sem lockout progressivo contra adivinhação de TOTP; ruído de reformatação em `institutional_auth_service`; confirmar que o log do Serverpod não registra o retorno de endpoint (`secretBase32`). +- T6: `onSessionExpired` pode disparar duas vezes com chamadas concorrentes; o ACK de alerta vermelho em voo na expiração é descartado (conferir se o alerta reaparece após novo login); preexistente: `renewSession` com falha não recuperável que não é MFA re-tenta o login a cada sync e pode bloquear a conta. +- T7: chaves privadas de cliente em modo 644 em dev; o teste "production sem TLS" só verifica a ausência do erro de certificado; cert/chave ignorados em silêncio se o TLS está desligado. +- T8: `sync_dev_ca.sh` não confere que `acs-area-12.key` é o par do certificado e deixa `.tmp` se interrompido; doc de `alert_feed.dart` passa de 100 colunas; `buildMqttSecurityContext` ignora certificado sem chave em silêncio. + ## Migração para Serverpod Trabalho posterior às Fases 1 e 2, fora da numeração M1.x/M2.x/M3.x do PRD. O From 4f114362aeddfa26f53c7582cad7db86f750abfc Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Fri, 2 Oct 2026 13:43:25 -0400 Subject: [PATCH 43/43] =?UTF-8?q?fix(acs):=20reautentica=C3=A7=C3=A3o=20po?= =?UTF-8?q?r=20MFA=20empilhada=20sobre=20o=20painel,=20sem=20perder=20aler?= =?UTF-8?q?tas,=20formul=C3=A1rio=20nem=20fila;=20ajustes=20de=20documenta?= =?UTF-8?q?=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- PROGRESS.md | 14 +- apps/acs/lib/app/app.dart | 71 ++++++--- .../core/database/micro_area_cache_store.dart | 2 +- .../core/services/micro_area_directory.dart | 2 +- apps/acs/test/mfa_login_test.dart | 21 --- apps/acs/test/session_reauth_test.dart | 136 ++++++++++++++++++ apps/acs/test/support/fakes.dart | 25 +++- backend/DEPLOY.md | 2 + docs/telas-acs.md | 4 + 10 files changed, 231 insertions(+), 48 deletions(-) create mode 100644 apps/acs/test/session_reauth_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 44c9a864..4be3b7af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), `FLAG_SECURE` on the ACS window, release builds signed with an own key (debug signing needs an explicit flag) and the UBS call button. Still missing: refresh token (deliberately deferred — with MFA on, the ACS session of 15 min cannot be renewed silently; see `PROGRESS.md`, section Pendências do ACS), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), MFA reset by the coordinator (manual today), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). +SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), `FLAG_SECURE` on the ACS window, release builds that refuse the debug key unless explicitly allowed (a release keystore is still to be provisioned) and the UBS call button. Still missing: refresh token (deliberately deferred — with MFA on, the ACS session of 15 min cannot be renewed silently; see `PROGRESS.md`, section Pendências do ACS), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), MFA reset by the coordinator (manual today), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). Two core flows: - **Paciente (patient)**: passwordless auth (OTP), urgency alert, structured triage, request status tracking, "Meus Dados" (LGPD). diff --git a/PROGRESS.md b/PROGRESS.md index b30478dd..42046155 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -570,7 +570,7 @@ Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `em - **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, refresh token, iOS, botão da UBS. **(fechado depois — ver a seção Pendências do ACS (2026-10-02): RF08, `FLAG_SECURE`, assinatura de release e botão da UBS estão fechados; refresh token e iOS seguem abertos.)** - **Fora:** refresh token, iOS (`apps/acs/ios/` não existe). - **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. -- **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa digitar matrícula + senha + código de novo quando a sessão de 15 minutos vence, porque ainda não existe refresh token** (o código TOTP é de uso único e não dá para renovar em silêncio): `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória, falha de forma não recuperável e `AcsBackend.onSessionExpired` leva o painel de volta ao login com o aviso; as visitas pendentes ficam na fila (disco) e a sincronização que falhou as mantém pendentes. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo seguinte). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). +- **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa se autenticar de novo (matrícula + senha + código) mais ou menos a cada 15 minutos**, porque ainda não existe refresh token e o código TOTP é de uso único: `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória e falha de forma não recuperável, e `AcsBackend.onSessionExpired` faz o painel EMPILHAR a tela de reautenticação por cima (uma só por vez). O painel não é desmontado: fila de alertas já recebidos por MQTT (o broker não reentrega o que foi confirmado com PUBACK), feed MQTT (autentica por senha+certificado, não pelo JWT), fila de visitas e formulários em andamento sobrevivem; login do MESMO usuário só dá `pop`, de OUTRO usuário descarta o painel (RNF06). Confirmar um alerta com a sessão vencida mostra a mensagem e o alerta segue sem confirmação. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo atual, depois de esperar o passo avançar); `test/session_reauth_test.dart` (5: alerta e formulário preservados, ACK com mensagem, outro usuário, sem rota dupla). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). - **mTLS do broker (2026-10-02).** Entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de desenvolvimento `assets/certs/acs_client.*`, copiado por `sync_dev_ca.sh`, gitignorado), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); o release com a chave de dev é barrado pelo Gradle (`-Psinalacs.allowDevClientKey=true` só para teste local). - **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. @@ -585,7 +585,7 @@ Plano: `docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08 - **T5 — MFA TOTP, backend (`d6718dd`, `dc517f7`).** RFC 6238, segredo cifrado AES-256-GCM, replay e corrida de ativação barrados (5 logins simultâneos, 1 sessão), `REQUIRE_ACS_MFA` exigida fora de `development`. Prova: suíte do backend. Aberto: sem lockout progressivo além do limite de 5 tentativas/15 min. - **T6 — MFA no app (`abb8438`, `69c19d4`).** Campo do código no login, tela de ativação com QR, expiração de sessão sem renovação silenciosa. Prova: `mfa_login_test`, `text_scale_test` e o último teste de `full_journey_e2e` no emulador. Aberto: ver a consequência da sessão de 15 min abaixo. - **T7 — mTLS, broker e backend (`38beea2`, `0e8484b`).** `require_certificate true`, senha e ACL mantidas, backend apresenta certificado. Prova: `scripts/qa/mtls_invariants.sh` (recusa sem certificado e de outra CA, aceite com ambos; distingue recusa de falha de infraestrutura). Aberto: só na stack local. -- **T8 — mTLS, app (`05fcd1c`).** O ACS apresenta o certificado de cliente (asset de desenvolvimento `assets/certs/acs_client.*`); o release com essa chave dentro falha a build. Prova: `e2e.sh --emulator` e `full_journey_e2e` contra o broker com mTLS. Aberto: **provisionamento de certificado por aparelho em produção** (CSR no cadastro, chave no Keystore). +- **T8 — mTLS, app (`05fcd1c`).** O ACS apresenta o certificado de cliente (asset de desenvolvimento `assets/certs/acs_client.*`); o release com essa chave dentro falha a build. Prova do app com mTLS: `./scripts/qa/e2e.sh --emulator` (smoke + `red_alert_cycle`) e `mtls_invariants.sh`; o `full_journey_e2e` NÃO cobre MQTT. Aberto: **provisionamento de certificado por aparelho em produção** (CSR no cadastro, chave no Keystore). - **T9 — registro e barra final.** Este texto, o `CLAUDE.md` da raiz e a barra completa (resultados no relatório da tarefa). ### Decisões do plano (D1–D8) @@ -601,20 +601,24 @@ Plano: `docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08 ### Consequência de produto a decidir -Com MFA ligada e sem refresh token, a sessão do ACS (15 min) não pode ser renovada em silêncio (o código TOTP é de uso único): **o ACS digita matrícula + senha + código a cada 15 minutos**. As visitas pendentes ficam na fila. A decisão é do usuário: implementar refresh token ou alongar a sessão do ACS. +Com MFA ligada e sem refresh token, a sessão do ACS (15 min) não pode ser renovada em silêncio (o código TOTP é de uso único): **o ACS reautentica com matrícula + senha + código cerca de a cada 15 minutos**; o painel, os alertas e os formulários são preservados (a tela de login é empilhada por cima). Refresh token continua aberto; a decisão é do usuário: implementá-lo ou alongar a sessão do ACS. ### Continua aberto Refresh token; redefinição de MFA por coordenador (hoje manual: zerar as quatro colunas `totp*` do ACS); provisionamento de certificado por aparelho em produção; keystore de release como segredo na CI; cadastro do telefone da UBS pelo backoffice; iOS. +- **Adivinhação de TOTP:** para quem já tem a senha, o orçamento é o bloqueio fixo de 5 tentativas por 15 min (cerca de 0,14% por dia); precisa de bloqueio progressivo antes de qualquer implantação fora do desenvolvimento. +- **Cadastro da MFA é trust-on-first-use:** quem souber a senha primeiro pode ativá-la; não há redefinição pelo próprio ACS e a redefinição por coordenador é manual. +- **`FLAG_SECURE` escurece captura e gravação:** `screencap`/`screenrecord` do ACS saem pretos, inclusive o pipeline de vídeo (`video/capture/lib.sh`, linhas 142 e 161). Consequência conhecida; um build de debug com opt-out documentado NÃO foi adicionado (decisão pendente). Para capturar telas do ACS hoje, só com a janela desprotegida (remover temporariamente a flag na `MainActivity`) ou fotografando o aparelho. + ### Minors adiados - T1: o script de `FLAG_SECURE` usa `awk` com `exit` sobre o pipe do `dumpsys` (risco de SIGPIPE com `pipefail`) e `sleep 6` fixo; `secure_window_test` só confere string no fonte; falta documentar como regenerar as capturas do ACS. - T2: `acs_release_signing.sh` não é hermético contra `SINALACS_KEYSTORE_*` já exportadas; `hasProperty` vale mesmo com `=false`; a mensagem não diz qual das quatro propriedades falta; `apksigner` escolhido por ordem lexical. - T3: o store do ORM faz duas consultas; "UBS não encontrada" vira `AlertPermissionException`; `capture-acs.sh` cita o rótulo antigo da UBS. -- T4: dois comentários ainda citam §5.7 em vez de §5.11 (`micro_area_cache_store.dart`, `micro_area_directory.dart`); relógio do aparelho voltado serve cache de mais de 72 h; o cache do dono anterior só some no próximo load; o guard do `main.dart` é por string. +- T4: relógio do aparelho voltado serve cache de mais de 72 h; o cache do dono anterior só some no próximo load; o guard do `main.dart` é por string. - T5: sem lockout progressivo contra adivinhação de TOTP; ruído de reformatação em `institutional_auth_service`; confirmar que o log do Serverpod não registra o retorno de endpoint (`secretBase32`). -- T6: `onSessionExpired` pode disparar duas vezes com chamadas concorrentes; o ACK de alerta vermelho em voo na expiração é descartado (conferir se o alerta reaparece após novo login); preexistente: `renewSession` com falha não recuperável que não é MFA re-tenta o login a cada sync e pode bloquear a conta. +- T6: preexistente: `renewSession` com falha não recuperável que não é MFA re-tenta o login a cada sync e pode bloquear a conta. - T7: chaves privadas de cliente em modo 644 em dev; o teste "production sem TLS" só verifica a ausência do erro de certificado; cert/chave ignorados em silêncio se o TLS está desligado. - T8: `sync_dev_ca.sh` não confere que `acs-area-12.key` é o par do certificado e deixa `.tmp` se interrompido; doc de `alert_feed.dart` passa de 100 colunas; `buildMqttSecurityContext` ignora certificado sem chave em silêncio. diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 1cda1f62..da50cbaf 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -157,8 +157,16 @@ class LoginScreen extends StatefulWidget { this.initialPosition, this.syncInterval, this.aviso, + this.reauthUserId, }); + /// Preenchido quando a tela é empilhada SOBRE o painel porque a sessão venceu + /// (MFA, sem refresh token): é o `userId` da sessão que o painel guarda. Mesmo + /// usuário: a tela só dá `pop` e o painel (fila de alertas, feed MQTT, fila de + /// visitas, formulários) segue vivo. Outro usuário: o painel antigo é + /// descartado (RNF06 — nada do território anterior aparece). + final String? reauthUserId; + /// Aviso fixo no topo do formulário (ex.: sessão expirada). final String? aviso; final AlertFeed Function(AlertQueue queue)? feedBuilder; @@ -236,7 +244,12 @@ class _LoginScreenState extends State { } if (!mounted) return; - Navigator.of(context).pushReplacement(MaterialPageRoute( + if (widget.reauthUserId != null && session.userId == widget.reauthUserId) { + Navigator.of(context).pop(true); + return; + } + + final shellRoute = MaterialPageRoute( builder: (_) => AcsHomeShell( microAreaId: microAreaId, acsId: session.userId, @@ -249,7 +262,13 @@ class _LoginScreenState extends State { themeController: widget.themeController, syncInterval: widget.syncInterval, ), - )); + ); + if (widget.reauthUserId != null) { + // Outro usuário: o painel de baixo é do território anterior. + Navigator.of(context).pushAndRemoveUntil(shellRoute, (_) => false); + } else { + Navigator.of(context).pushReplacement(shellRoute); + } } on MfaCodeRequired { if (!mounted) return; setState(() { _pedeCodigo = true; _error = null; _busy = false; }); @@ -273,7 +292,10 @@ class _LoginScreenState extends State { } @override - Widget build(BuildContext context) => Scaffold( + Widget build(BuildContext context) => PopScope( + // Reautenticação não é descartável: sem sessão o painel não renova nada. + canPop: widget.reauthUserId == null, + child: Scaffold( appBar: _Header('Segurança e rastreabilidade', 'Acesso institucional', height: acsHeaderHeight(context)), body: Center(child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), @@ -342,7 +364,7 @@ class _LoginScreenState extends State { ]))), ]), )), - ); + )); } enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices, invite, settings } @@ -513,12 +535,19 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver AcsBackend? _backend; - /// A sessão venceu e não renova sozinha (MFA, sem refresh token): volta ao - /// login. A fila offline é a mesma instância e segue com as visitas pendentes. - void _sessionExpired() { - if (!mounted) return; - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( + /// Só uma tela de reautenticação por vez (duas expirações seguidas não empilham). + bool _reauthAberta = false; + + /// A sessão venceu e não renova sozinha (MFA, sem refresh token). NÃO derruba o + /// painel: a reautenticação é empilhada por cima, e a fila de alertas, o feed + /// MQTT (autentica pelo broker, não pelo JWT), a fila de visitas e qualquer + /// formulário em andamento seguem vivos por baixo. Um alerta já recebido por + /// MQTT não é reentregue pelo broker; descartá-lo seria perdê-lo em silêncio. + Future _sessionExpired() async { + if (!mounted || _reauthAberta) return; + _reauthAberta = true; + try { + await Navigator.of(context).push(MaterialPageRoute( builder: (_) => LoginScreen( visitQueue: widget.visitQueue, themeController: widget.themeController, @@ -529,10 +558,12 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver initialPosition: widget.initialPosition, syncInterval: widget.syncInterval, aviso: 'Sua sessão expirou. Entre novamente com o código do autenticador.', + reauthUserId: widget.acsId, ), - ), - (_) => false, - ); + )); + } finally { + _reauthAberta = false; + } } Future _loadMicroAreaPatients() async { @@ -814,16 +845,20 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver } Future _acknowledge(PrioritizedAlert alert) async { + // O messenger vem do MaterialApp: sobrevive à troca de rota e ao painel. + final messenger = ScaffoldMessenger.maybeOf(context); + void aviso(String texto) => messenger + ?..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(texto))); try { final result = await BackendScope.of(context).acknowledge(alertId: alert.alertId); - if (!mounted) return; - if (result.acknowledged) _queue.markAcknowledged(alert.alertId); - _message(context, result.acknowledged + if (result.acknowledged && mounted) _queue.markAcknowledged(alert.alertId); + aviso(result.acknowledged ? 'Recebimento confirmado à central.' : 'A central não reconheceu este alerta.'); } on BackendFailure catch (failure) { - if (!mounted) return; - _message(context, failure.message); + // Nunca engolir: o alerta segue na fila, sem confirmação, e a pessoa vê por quê. + aviso(failure.message); } } diff --git a/apps/acs/lib/core/database/micro_area_cache_store.dart b/apps/acs/lib/core/database/micro_area_cache_store.dart index a7ec9258..cf62f349 100644 --- a/apps/acs/lib/core/database/micro_area_cache_store.dart +++ b/apps/acs/lib/core/database/micro_area_cache_store.dart @@ -14,7 +14,7 @@ class CachedMicroArea { /// Última lista da microárea no aparelho (RF08), na base SQLCipher das visitas. /// -/// Política em `spec/lgpd_design.md` §5.7: a lista pertence a um dono +/// Política em `spec/lgpd_design.md` §5.11: a lista pertence a um dono /// (`userId|microAreaId`); [read] de **outro** dono apaga o cache em vez de /// só escondê-lo — dado de outro território não fica no disco esperando uma /// confusão. diff --git a/apps/acs/lib/core/services/micro_area_directory.dart b/apps/acs/lib/core/services/micro_area_directory.dart index 97c358f7..b54e24de 100644 --- a/apps/acs/lib/core/services/micro_area_directory.dart +++ b/apps/acs/lib/core/services/micro_area_directory.dart @@ -5,7 +5,7 @@ import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; -/// Validade do cache da microárea (spec/lgpd_design.md §5.7). +/// Validade do cache da microárea (spec/lgpd_design.md §5.11). const microAreaCacheMaxAge = Duration(hours: 72); class MicroAreaSnapshot { diff --git a/apps/acs/test/mfa_login_test.dart b/apps/acs/test/mfa_login_test.dart index a2ddcd37..59bbb03e 100644 --- a/apps/acs/test/mfa_login_test.dart +++ b/apps/acs/test/mfa_login_test.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/app/app.dart'; -import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'support/fakes.dart'; -import 'support/layout_harness.dart' show assentar; Future entrar(WidgetTester tester) async { await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); @@ -105,23 +103,4 @@ void main() { expect(find.byKey(const Key('mfa_secret')), findsOneWidget); expect(find.byKey(const Key('mfa_error')), findsNothing); }); - - testWidgets('o painel volta ao login com o aviso, sem perder a fila', (tester) async { - final fake = FakeAcsBackend(); - final fila = OfflineVisitQueue(); - await fila.add(OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); - await tester.pumpWidget(SinalAcsApp(backend: fake, visitQueue: fila, feedBuilder: (q) => FakeAlertFeed(q))); - await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); - await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); - await tester.tap(find.byKey(const Key('login_button'))); - await assentar(tester); - expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); - - fake.onSessionExpired!(); - await assentar(tester); - - expect(find.byKey(const Key('login_button')), findsOneWidget); - expect(find.text('Sua sessão expirou. Entre novamente com o código do autenticador.'), findsOneWidget); - expect(fila.pendingCount, 1); - }); } diff --git a/apps/acs/test/session_reauth_test.dart b/apps/acs/test/session_reauth_test.dart new file mode 100644 index 00000000..e69c5413 --- /dev/null +++ b/apps/acs/test/session_reauth_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar, irParaDaBarra; + +const _aviso = 'Sua sessão expirou. Entre novamente com o código do autenticador.'; + +/// Sessão vencida com MFA: a reautenticação é empilhada SOBRE o painel. Nada do +/// que o painel guarda em memória (alertas já entregues por MQTT, formulário, +/// fila de visitas) pode se perder — um alerta vermelho nunca some em silêncio. +void main() { + late FakeAcsBackend backend; + late FakeAlertFeed feed; + late OfflineVisitQueue fila; + + Future abrir(WidgetTester tester) async { + backend = FakeAcsBackend() + ..expectedTotpCode = '123456' + ..patients = [ + MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Fulano de Tal', isChronic: false, chronicConditions: const []), + ]; + fila = OfflineVisitQueue(); + await fila.add(OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await tester.pumpWidget(SinalAcsApp(backend: backend, visitQueue: fila, feedBuilder: (q) => feed = FakeAlertFeed(q))); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + await _digitarCodigo(tester); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); + feed.deliver(testAlert(alertId: 'alerta-vermelho')); + await assentar(tester); + expect(find.byKey(const Key('alert_alerta-vermelho')), findsOneWidget); + } + + testWidgets('(a) a sessão expira: login empilhado, e o alerta continua na fila sem confirmação', (tester) async { + await abrir(tester); + + backend.expireSession(); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text(_aviso), findsOneWidget); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + await _digitarCodigo(tester); + + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'voltou ao painel'); + expect(find.byKey(const Key('alert_alerta-vermelho')), findsOneWidget); + expect(find.text('Confirmar recebimento'), findsOneWidget, reason: 'segue sem confirmação'); + expect(backend.acknowledgedAlertIds, isEmpty); + expect(fila.pendingCount, 1); + }); + + testWidgets('(b) confirmar depois de expirar mostra a mensagem e o alerta segue sem confirmação', (tester) async { + await abrir(tester); + backend.sessionExpired = true; // vence sem avisar a UI: o aviso vem da própria chamada + + await tester.tap(find.text('Confirmar recebimento')); + await assentar(tester); + + expect(find.text(_aviso), findsWidgets, reason: 'mensagem visível (tela de reautenticação)'); + expect(find.byType(SnackBar), findsOneWidget); + expect(backend.acknowledgedAlertIds, isEmpty); + + // A mensagem cobre o rodapé da tela de login: dispensa para tocar no botão. + ScaffoldMessenger.of(tester.element(find.byKey(const Key('login_button')))).hideCurrentSnackBar(); + await assentar(tester); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + await _digitarCodigo(tester); + expect(find.text('Confirmar recebimento'), findsOneWidget, reason: 'o alerta não sumiu nem foi confirmado'); + }); + + testWidgets('(c) o formulário de visita pela metade sobrevive à reautenticação', (tester) async { + await abrir(tester); + await irParaDaBarra(tester, 'Visita'); + await tester.enterText(find.byKey(const Key('patient_search')), 'busca em andamento'); + await tester.pump(); + + backend.expireSession(); + await assentar(tester); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + await _digitarCodigo(tester); + + expect(tester.widget(find.byKey(const Key('patient_search'))).controller!.text, 'busca em andamento'); + }); + + testWidgets('(d) outro usuário entra: a fila do anterior não é mantida', (tester) async { + await abrir(tester); + backend.expireSession(); + await assentar(tester); + backend.nextUserId = '00000000-0000-4000-8000-0000000000ff'; + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-002'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + await _digitarCodigo(tester); + + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'painel novo aberto'); + expect(find.byKey(const Key('alert_alerta-vermelho')), findsNothing, reason: 'RNF06: nada do usuário anterior'); + }); + + testWidgets('(e) duas expirações seguidas empilham uma única reautenticação', (tester) async { + await abrir(tester); + + backend.expireSession(); + backend.expireSession(); + await assentar(tester); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + // Um único `pop` volta ao painel: só havia uma rota por cima. + tester.state(find.byType(Navigator).first).pop(); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsNothing); + }); +} + +/// Depois de matrícula e senha o fake pede o código: digita e entra. +Future _digitarCodigo(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); +} diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 6622f698..5adb755d 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -92,10 +92,12 @@ class FakeAcsBackend implements AcsBackend { loginCount++; lastCredentials = (matricula: matricula, senha: senha); lastTotpCode = totpCode; + if (sessionExpired && expectedTotpCode != null && totpCode == null) throw const MfaCodeRequired(); if (mfaEnrollmentRequired) throw const MfaEnrollmentRequired(); if (expectedTotpCode != null && totpCode != expectedTotpCode) { throw totpCode == null ? const MfaCodeRequired() : const BackendFailure('Código de verificação inválido.', isRecoverable: false); } + sessionExpired = false; return _issueSession(); } @@ -113,7 +115,7 @@ class FakeAcsBackend implements AcsBackend { final session = AuthSession( accessToken: 'token-de-teste', tokenType: 'Bearer', - userId: seedAcsId, + userId: nextUserId, role: 'acs', microAreaId: microAreaId, expiresAt: DateTime.now().toUtc().add(const Duration(minutes: 15)), @@ -122,8 +124,29 @@ class FakeAcsBackend implements AcsBackend { return session; } + /// Sessão vencida com MFA: toda chamada autenticada falha como o + /// `BackendClient` real (não recuperável) e avisa a UI uma única vez. + bool sessionExpired = false; + + /// `userId` da próxima sessão emitida (padrão: o ACS do seed). + String nextUserId = seedAcsId; + + /// Vence a sessão agora, como a renovação que recebeu `MfaRequiredException`. + void expireSession() { + sessionExpired = true; + _session = null; + onSessionExpired?.call(); + } + @override Future acknowledge({required String alertId}) async { + if (sessionExpired) { + onSessionExpired?.call(); + throw const BackendFailure( + 'Sua sessão expirou. Entre novamente com o código do autenticador.', + isRecoverable: false, + ); + } acknowledgedAlertIds.add(alertId); return AlertAckResult( alertId: alertId, diff --git a/backend/DEPLOY.md b/backend/DEPLOY.md index 3ec42baa..10616a71 100644 --- a/backend/DEPLOY.md +++ b/backend/DEPLOY.md @@ -88,6 +88,8 @@ Configurar como variáveis de ambiente secretas (nunca commitadas): | `APP_ENV` | `production` | | `ENABLE_DEV_LOGIN` | `true` (decisão consciente — é o único mecanismo de auth do piloto) | +> Com `APP_ENV=production` e `MQTT_USE_TLS=true` o servidor agora exige `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` (mTLS). Com `ENABLE_DEV_LOGIN=true` a MFA do ACS é contornada neste caminho de demonstração (decisão preexistente). + > **TLS (RNF04).** O container **não** atende RNF04 sozinho: o Serverpod continua > escutando em **texto claro** na porta que o Render injeta em `PORT`, e quem > termina TLS é o proxy do Render, na borda — por isso a URL do serviço é diff --git a/docs/telas-acs.md b/docs/telas-acs.md index 1c902efe..227c70a8 100644 --- a/docs/telas-acs.md +++ b/docs/telas-acs.md @@ -53,3 +53,7 @@ Permite preparar público-alvo e mensagem comunitária. O envio depende da futur - [Implementação Flutter](../apps/acs/lib/app/app.dart) - [Protótipos de referência](../spec/ui_acs) - [Guia visual](../spec/ui_design.md) + +## Captura de tela + +A janela do ACS é `FLAG_SECURE`: `adb screencap` e `screenrecord` saem pretos (inclusive `video/capture/lib.sh`). Não há opt-out de debug por enquanto (decisão pendente); para regenerar capturas é preciso retirar a flag temporariamente da `MainActivity`.