28 changed files with 2031 additions and 322 deletions
@ -0,0 +1,85 @@ |
|||
# Praktikumssoftware 2026.10.02.1 – Geschützter Praxisexport |
|||
|
|||
Stand: 02.10.2026. Ausgangspunkt Praxisverwaltung 0.117.0 und Praktikumssoftware Build 2026.08.20.1. Die Änderungen aus 0.117.0 bleiben enthalten. Passender neuer Praktikums-Build: 2026.10.02.1 (Frontend-Paket 1.1.0). |
|||
|
|||
## Bedienung |
|||
|
|||
1. Beide Anwendungen aktualisieren und ihre Datenbankmigrationen ausführen. |
|||
2. In der Praktikumssoftware anmelden und **Monatsbericht** öffnen. Zeitraum und ein eigenes Exportpasswort mit mindestens zwölf Zeichen angeben. **Verschlüsselten Praxisexport herunterladen** wählen. |
|||
3. In der Praxissoftware im gewünschten eigenen Konto anmelden. Über **Schnell finden → Praktikumsdaten importieren** oder **Fahrten & Fortbildung → Daten & Notfallplan** den Import öffnen. |
|||
4. `.pcmtransfer`-Datei und Exportpasswort eingeben. **Vorschau prüfen**, neue Einträge auswählen und die fachliche Prüfung bestätigen. **Auswahl verbindlich importieren** legt nur diese Datensätze an. |
|||
5. Fahrten erscheinen im Fahrtenbuch, Lernaktivitäten unter Bildung & Nachweise. Kategorien, Ausbildungsphase, Kilometeransatz und fehlende Strecken/Fahrzeugdaten prüfen. Nachweisdateien können anschließend am Bildungseintrag ergänzt werden. |
|||
|
|||
Dies ist ein geschützter, bewusst ausgelöster Dateiaustausch. Es handelt sich nicht um eine laufende Synchronisation oder um ein vollständiges Backup. |
|||
|
|||
## Daten und fachliche Grenzen |
|||
|
|||
- Datum, Ausbildungsbereich, Tätigkeitsart und Originalminuten werden übernommen. Stunden werden auf zwei Dezimalstellen umgerechnet; Originalminuten bleiben in den Notizen. |
|||
- Kilometer und der im Quellsystem konfigurierte Jahressatz werden übernommen. Fehlt dort ein Satz, verwendet der Export den bestehenden Quellsystem-Ersatzwert 0,42 EUR/km und markiert ihn intern als Ersatzwert. **Jeder importierte Kilometeransatz ist selbst zu prüfen**; die Software ermittelt hier keine steuerliche Zulässigkeit. |
|||
- Kosten bleiben Herkunftsnotizen. Es entstehen weder Ausgabenbuchungen noch Zahlungszuordnungen. |
|||
- Ausbildungsaktivitäten werden zunächst als `training` erfasst. Vergangene Aktivitäten sind `completed`, zukünftige `planned`. Keine ECTS-Umrechnung, keine automatische Mediationsstunden-Zuordnung oder behördliche Anerkennung. |
|||
- Unbekannte Tätigkeitsarten werden als Sonstiges gespeichert. Quelle und Originalart bleiben nachvollziehbar. |
|||
- Beschreibungen werden nur bei ausdrücklicher Auswahl im Export mitgenommen. Standardmäßig sind sie ausgeschlossen. Namen von Klienten, Kontopasswörter, E-Mail-Adressen und API-Tokens sind keine Exportfelder. |
|||
- Das vorhandene Quellmodell besitzt keine angehängten Nachweisdateien. Diese werden deshalb nicht übertragen und können im Ziel ergänzt werden. |
|||
- Laufende Zeiterfassungen werden ausgelassen und als Anzahl angezeigt. Einträge ohne Minuten und ohne Kilometer erzeugen keinen Ziel-Datensatz. Maximal 500 Quelleinträge beziehungsweise 1.000 Zielzeilen pro Export, maximal 4 MiB Datei. |
|||
- Ein Quelleintrag mit Kilometern und Zeit wird zu zwei unabhängig auswählbaren Zielzeilen: Fahrt und Lernaktivität. |
|||
|
|||
## Schutzmaßnahmen |
|||
|
|||
AES-256-GCM mit zufälligem Salt und IV; Schlüsselableitung PBKDF2-HMAC-SHA256 mit 600.000 Iterationen. Das Passwort steht nicht in der Datei. Veränderung und falsches Passwort führen zur Ablehnung. Algorithmus und KDF-Aufwand sind fest vorgegeben. Das Format ist versioniert. |
|||
|
|||
Export und Import benötigen jeweils die Anmeldung im betreffenden Konto. Die Quelle exportiert nur eigene Einträge. Das Ziel ignoriert keine fremden Benutzerangaben, sondern lehnt nicht unterstützte Felder ab; sämtliche Zielzuordnungen stammen vom angemeldeten Benutzer. Nur klinisch berechtigte Praxisrollen dürfen importieren. Vorhandene Verschlüsselung der Zielmodelle bleibt erhalten. |
|||
|
|||
Eine signierte Vorschau ist 15 Minuten gültig und an Benutzer und Dateihash gebunden. Auswahl und Dubletten werden beim Speichern erneut unter Kontosperre geprüft. Datensätze, Importbelege und Audit-Ereignis werden transaktional gespeichert. Bereits importierte Datensätze werden übersprungen; Änderungen erzeugen einen Konflikt. Gelöschte Ziel-Datensätze werden nicht unbemerkt erneut angelegt. Importbelege enthalten nur gehashte Quellidentität/Fingerabdruck und Zielreferenzen, keine Beschreibung. Sie bleiben nach einer einzelnen Datensatzlöschung als Dublettenschutz erhalten, bis das Konto gelöscht wird. |
|||
|
|||
Passwörter/Vorschautoken sind aus Rails-Parameterlogs gefiltert. Antworten haben `Cache-Control: no-store`. Kein Passwort oder entschlüsselter Vorschauinhalt wird in Local Storage gespeichert. Passwortfelder werden nach erfolgreicher Übernahme geleert. Die Benutzeroberfläche verweigert Datenaustausch auf unverschlüsselten entfernten HTTP-Seiten; lokale Entwicklungsadressen sind ausgenommen. Die Produktions-API verlangt HTTPS zusätzlich serverseitig. |
|||
|
|||
In der Praktikums-API wurde außerdem behoben, dass ein fehlender Token-Digest bei einem Konto ohne Token passen konnte. Fehlende Tokens und andere Authentifizierungsschemata als Bearer werden jetzt abgelehnt. |
|||
|
|||
## Installation und HTTPS |
|||
|
|||
Vor dem Update **jede Datenbank separat sichern** und vorhandene Konfiguration/Schlüssel behalten. Die beiden Projektordner gehören in ihre jeweiligen bestehenden Installationen, nicht ineinander. Keine neue `.env` über eine bestehende Serverkonfiguration kopieren. |
|||
|
|||
Neue Migrationen: |
|||
|
|||
- Praxis: `20261002090000_create_professional_import_receipts.rb` |
|||
- Praktikum: `20261002080000_add_practice_transfer_id_to_users.rb` |
|||
|
|||
In jedem Projekt die neuen Images bauen, `db:prepare` vor dem Neustart ausführen und erst nach erfolgreichem Healthcheck wieder freigeben. Die Praxis verwendet weiterhin ihren vorhandenen Deploy-/Rollback-Prozess. Bei einer manuellen Wartungsinstallation sind die Kernschritte je Projekt: |
|||
|
|||
```sh |
|||
docker compose build |
|||
docker compose run --rm api bin/rails db:prepare |
|||
docker compose up -d |
|||
``` |
|||
|
|||
Diese Befehle setzen laufende Datenbank/Redis und korrekt gesetzte bestehende Umgebungsvariablen voraus. Vorherige Images und Datenbanksicherung für einen kontrollierten Rollback behalten. Bei Rückkehr zum alten Code die neuen Importtabellen/-spalten stehen lassen; **keine automatische Down-Migration**, da sie den Dublettenschutz entfernen würde. Vor einem erneuten Import nach einer Datenbankwiederherstellung beide Datenstände prüfen. |
|||
|
|||
Beide öffentlichen Anwendungen über HTTPS bereitstellen. API-Ports dürfen nur dem vertrauenswürdigen Reverse Proxy zugänglich sein. In der Praxis muss der bestehende sichere Cookie-/TLS-Proxy-Betrieb korrekt konfiguriert sein. In der Praktikumssoftware ist neu `ASSUME_SSL` an den API-Container durchgereicht: nur hinter einem tatsächlich TLS-erzwingenden, vertrauenswürdigen Proxy auf `true` setzen. Alternativ den ursprünglichen HTTPS-Status korrekt bis Rails durchreichen. `ASSUME_SSL=true` schaltet selbst kein TLS ein. Ein HTTP-Produktionsaufruf wird mit HTTP 426 und verständlichem Hinweis abgelehnt. |
|||
|
|||
Keine Produktionsgeheimnisse im Paket. Vorhandene private Credentials-Dateien bleiben Teil der jeweiligen Installation. Entwicklungs-Datenbankpasswörter werden ebenfalls über Umgebungsvariablen gesetzt. |
|||
|
|||
## Geprüft und offen |
|||
|
|||
- Praxis: Produktionsbuild erfolgreich; 143 Frontend-Tests in 37 Dateien erfolgreich. |
|||
- Praktikum: Produktionsbuild erfolgreich; 4 Frontend-Tests in 2 Dateien erfolgreich. |
|||
- Praxis: 51 HTML-Templates auf Übersetzungsabdeckung geprüft, keine fehlende englische Übersetzung im automatisierten Template-Check. UI-Konsistenzprüfung erfolgreich. |
|||
- `npm audit`: in beiden aktualisierten Frontends 0 gemeldete Schwachstellen. Praktikum: Angular-Runtime 22.2.1, Build/CLI 22.1.4, gezielter Piscina-Override 5.3.2. Keine erzwungene Major-Aktualisierung. Lockfiles enthalten die geprüften Versionen. |
|||
- Praxisbuild: fünf bestehende SCSS-Budgetwarnungen außerhalb der neuen Importkomponente; kein Buildfehler. |
|||
- Rails-Spezifikationen für Verschlüsselung, Kontotrennung, Auswahl, Dubletten, Konflikte, Löschschutz, Transaktion und Vorschau-Bindung sowie Quell-API-Authentifizierung ergänzt. **Nicht ausgeführt:** Ruby/Bundler und PostgreSQL sind hier nicht verfügbar. `bundle exec rspec` und `ruby test/practice_transfer_test.rb` konnten deshalb nicht starten. |
|||
- Kein Docker-Build, kein Produktivdeployment und kein browsergestützter Ende-zu-Ende-Test gegen laufende Backends ausgeführt. Migrationen und verschlüsselter Roundtrip sind vor Freigabe auf einer Testinstallation auszuführen. |
|||
|
|||
Backend-Prüfung auf einer geeigneten Testumgebung: |
|||
|
|||
```sh |
|||
# Praxis/backend, ausschließlich separate Testdatenbank: |
|||
RAILS_ENV=test bundle exec rails db:prepare |
|||
bundle exec rspec spec/services/professional_transfer_envelope_spec.rb spec/services/professional_transfer_import_spec.rb spec/requests/api/v1/professional_transfers_spec.rb |
|||
# Praktikumsprojekt, ausschließlich separate Testdatenbank: |
|||
RAILS_ENV=test bundle exec rails db:prepare |
|||
bundle exec ruby test/practice_transfer_test.rb |
|||
``` |
|||
|
|||
## English summary |
|||
|
|||
This release adds an authenticated, password-encrypted file transfer from the internship application to the practice application. Open Monthly report in the updated source app to export a date range, then use **Import internship records** in practice quick find. Review and select records before confirming. Repeated imports are skipped; changed sources are marked for manual review. Learning activities initially use the training phase. No automatic professional credit, certificate transfer, accounting entry or ongoing synchronization is performed. Both applications and migrations are required. Frontend builds/tests and dependency audits pass; Rails, migration and live end-to-end verification remain pending because the required runtimes are unavailable here. |
|||
@ -0,0 +1,31 @@ |
|||
module Api |
|||
module V1 |
|||
class PracticeTransfersController < BaseController |
|||
before_action { response.set_header("Cache-Control", "no-store") } |
|||
|
|||
before_action :require_secure_transfer |
|||
|
|||
def create |
|||
payload = PracticeTransferExport.new(user: current_user, from: params[:from], to: params[:to], |
|||
descriptions: ActiveModel::Type::Boolean.new.cast(params[:include_descriptions])).call |
|||
encrypted = ProfessionalTransferEnvelope.encrypt(payload, params[:transfer_password]) |
|||
response.set_header("Cache-Control", "no-store") |
|||
response.set_header("X-Transfer-Skipped-Running", payload[:skipped_running].to_s) |
|||
send_data encrypted, type: "application/octet-stream", disposition: "attachment", |
|||
filename: "praktikum-praxis-#{Date.current.iso8601}.pcmtransfer" |
|||
rescue PracticeTransferExport::Invalid => error |
|||
render json: { error: error.message }, status: :unprocessable_entity |
|||
rescue ProfessionalTransferEnvelope::Invalid => error |
|||
message = error.message == "password" ? "Das Transferpasswort muss mindestens 12 Zeichen lang sein (höchstens 256 Bytes)." : "Der Export ist zu groß. Bitte den Zeitraum verkleinern." |
|||
render json: { error: message }, status: :unprocessable_entity |
|||
end |
|||
private |
|||
|
|||
def require_secure_transfer |
|||
if Rails.env.production? && !request.ssl? |
|||
render json: { error: "Der Praxisexport ist nur über HTTPS erlaubt. Bitte die TLS-Proxy-Konfiguration prüfen." }, status: :upgrade_required |
|||
end |
|||
end |
|||
end |
|||
end |
|||
end |
|||
@ -0,0 +1,48 @@ |
|||
require "date" |
|||
require "bigdecimal" |
|||
require "securerandom" |
|||
|
|||
class PracticeTransferExport |
|||
class Invalid < StandardError; end |
|||
MAX_ENTRIES = 500 |
|||
|
|||
def initialize(user:, from:, to:, descriptions: false) |
|||
@user, @from, @to, @descriptions = user, Date.iso8601(from.to_s), Date.iso8601(to.to_s), descriptions |
|||
raise Invalid, "Der Zeitraum ist ungültig." if @from > @to || !(1900..2200).cover?(@from.year) || !(1900..2200).cover?(@to.year) |
|||
rescue Date::Error |
|||
raise Invalid, "Bitte Beginn und Ende des Zeitraums angeben." |
|||
end |
|||
|
|||
def call |
|||
entries = @user.entries.where(date: @from..@to).order(:id).limit(MAX_ENTRIES + 1).to_a |
|||
raise Invalid, "Höchstens 500 Einträge je Export. Bitte den Zeitraum verkleinern." if entries.length > MAX_ENTRIES |
|||
namespace = @user.with_lock do |
|||
@user.update!(practice_transfer_id: SecureRandom.uuid) if @user.practice_transfer_id.blank? |
|||
@user.practice_transfer_id |
|||
end |
|||
rates = MileageRate.where(year: (@from.year..@to.year)).pluck(:year, :rate_per_km).to_h |
|||
skipped = 0 |
|||
rows = entries.flat_map do |entry| |
|||
if entry.start_time.present? && entry.end_time.nil? |
|||
skipped += 1 |
|||
next [] |
|||
end |
|||
hours, minutes = entry.hours_and_minutes |
|||
total_minutes = hours * 60 + minutes |
|||
common = { source_id: entry.id.to_s, date: entry.date.iso8601, education: entry.praktikums_typ, |
|||
activity: entry.entry_art, duration_minutes: total_minutes, |
|||
description: @descriptions ? entry.beschreibung.to_s.first(4_000) : nil, |
|||
cost_cents: entry.kosten ? (entry.kosten.to_d * 100).round.to_i : nil } |
|||
result = [] |
|||
if entry.distance_km.to_i.positive? |
|||
rate = rates[entry.date.year] || BigDecimal("0.42") # Existing source application fallback, not a tax determination. |
|||
result << common.merge(type: "mileage", kilometers: entry.distance_km, rate_cents: (rate.to_d * 100).round.to_i, |
|||
rate_origin: rates.key?(entry.date.year) ? "configured" : "source_default") |
|||
end |
|||
result << common.merge(type: "development") if total_minutes.positive? |
|||
result |
|||
end |
|||
{ format: "pcm-professional-transfer-v1", source_namespace: namespace, exported_at: Time.current.iso8601, |
|||
from: @from.iso8601, to: @to.iso8601, skipped_running: skipped, records: rows } |
|||
end |
|||
end |
|||
@ -0,0 +1,57 @@ |
|||
require "openssl" |
|||
require "base64" |
|||
require "json" |
|||
|
|||
# Portable authenticated encryption, identical in both applications. |
|||
# The file contains no password or key. Never accept client-selected algorithms/KDF costs. |
|||
class ProfessionalTransferEnvelope |
|||
FORMAT = "pcm-professional-transfer-encrypted-v1".freeze |
|||
AAD = "pcm-professional-transfer:v1".freeze |
|||
ITERATIONS = 600_000 |
|||
MAX_FILE_BYTES = 4 * 1024 * 1024 |
|||
class Invalid < StandardError; end |
|||
|
|||
def self.encrypt(payload, password) |
|||
check_password!(password) |
|||
salt = OpenSSL::Random.random_bytes(16) |
|||
iv = OpenSSL::Random.random_bytes(12) |
|||
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt |
|||
cipher.key = key(password, salt) |
|||
cipher.iv = iv |
|||
cipher.auth_data = AAD |
|||
ciphertext = cipher.update(JSON.generate(payload)) + cipher.final |
|||
envelope = { format: FORMAT, salt: Base64.strict_encode64(salt), iv: Base64.strict_encode64(iv), |
|||
tag: Base64.strict_encode64(cipher.auth_tag), ciphertext: Base64.strict_encode64(ciphertext) } |
|||
output = JSON.generate(envelope) |
|||
raise Invalid, "too_large" if output.bytesize > MAX_FILE_BYTES |
|||
output |
|||
end |
|||
|
|||
def self.decrypt(file, password) |
|||
check_password!(password) |
|||
raise Invalid, "too_large" if file.bytesize > MAX_FILE_BYTES |
|||
envelope = JSON.parse(file, max_nesting: 5) |
|||
raise Invalid, "invalid_file" unless envelope.is_a?(Hash) && envelope.keys.sort == %w[ciphertext format iv salt tag] && envelope["format"] == FORMAT |
|||
salt, iv, tag, ciphertext = %w[salt iv tag ciphertext].map { |name| Base64.strict_decode64(envelope.fetch(name)) } |
|||
raise Invalid, "invalid_file" unless salt.bytesize == 16 && iv.bytesize == 12 && tag.bytesize == 16 && ciphertext.bytesize.positive? |
|||
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt |
|||
cipher.key = key(password, salt) |
|||
cipher.iv = iv |
|||
cipher.auth_tag = tag |
|||
cipher.auth_data = AAD |
|||
JSON.parse(cipher.update(ciphertext) + cipher.final, max_nesting: 10) |
|||
rescue Invalid |
|||
raise |
|||
rescue JSON::ParserError, ArgumentError, TypeError, KeyError, OpenSSL::Cipher::CipherError |
|||
raise Invalid, "invalid_file" |
|||
end |
|||
|
|||
def self.check_password!(password) |
|||
raise Invalid, "password" unless password.is_a?(String) && password.length >= 12 && password.bytesize <= 256 |
|||
end |
|||
|
|||
def self.key(password, salt) |
|||
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, ITERATIONS, 32, "sha256") |
|||
end |
|||
private_class_method :key, :check_password! |
|||
end |
|||
@ -1,2 +1,2 @@ |
|||
Rails.application.config.x.build_version = "2026.08.20.1" |
|||
Rails.logger.info("Praktikum API build 2026.08.20.1 loaded") |
|||
Rails.application.config.x.build_version = "2026.10.02.1" |
|||
Rails.logger.info("Praktikum API build 2026.10.02.1 loaded") |
|||
@ -0,0 +1,6 @@ |
|||
class AddPracticeTransferIdToUsers < ActiveRecord::Migration[7.1] |
|||
def change |
|||
add_column :users, :practice_transfer_id, :uuid |
|||
add_index :users, :practice_transfer_id, unique: true |
|||
end |
|||
end |
|||
@ -1,9 +1,71 @@ |
|||
{ |
|||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects", |
|||
"projects": { "praktikum": { "projectType": "application", "root": "", "sourceRoot": "src", "prefix": "app", |
|||
"architect": { "build": { "builder": "@angular/build:application", "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "assets": [{"glob":"**/*","input":"public"}], "styles": ["node_modules/@fontsource-variable/inter/index.css", "src/styles.scss"] }, |
|||
"configurations": { "production": { "outputHashing": "all", "budgets": [{"type":"initial","maximumWarning":"1MB","maximumError":"2MB"}] } }, "defaultConfiguration": "production" }, |
|||
"serve": { "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "praktikum:build:production" }, "development": { "buildTarget": "praktikum:build" } }, "defaultConfiguration": "development" } |
|||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", |
|||
"version": 1, |
|||
"newProjectRoot": "projects", |
|||
"projects": { |
|||
"praktikum": { |
|||
"projectType": "application", |
|||
"root": "", |
|||
"sourceRoot": "src", |
|||
"prefix": "app", |
|||
"architect": { |
|||
"build": { |
|||
"builder": "@angular/build:application", |
|||
"options": { |
|||
"browser": "src/main.ts", |
|||
"tsConfig": "tsconfig.app.json", |
|||
"assets": [ |
|||
{ |
|||
"glob": "**/*", |
|||
"input": "public" |
|||
} |
|||
], |
|||
"styles": [ |
|||
"node_modules/@fontsource-variable/inter/index.css", |
|||
"src/styles.scss" |
|||
] |
|||
}, |
|||
"configurations": { |
|||
"production": { |
|||
"outputHashing": "all", |
|||
"budgets": [ |
|||
{ |
|||
"type": "initial", |
|||
"maximumWarning": "1MB", |
|||
"maximumError": "2MB" |
|||
} |
|||
] |
|||
}, |
|||
"development": { |
|||
"optimization": false, |
|||
"extractLicenses": false, |
|||
"sourceMap": true |
|||
} |
|||
}, |
|||
"defaultConfiguration": "production" |
|||
}, |
|||
"serve": { |
|||
"builder": "@angular/build:dev-server", |
|||
"configurations": { |
|||
"production": { |
|||
"buildTarget": "praktikum:build:production" |
|||
}, |
|||
"development": { |
|||
"buildTarget": "praktikum:build" |
|||
} |
|||
}, |
|||
"defaultConfiguration": "development" |
|||
}, |
|||
"test": { |
|||
"builder": "@angular/build:unit-test", |
|||
"options": { |
|||
"tsConfig": "tsconfig.spec.json" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
"cli": { |
|||
"analytics": false |
|||
} |
|||
} }, "cli": { "analytics": false } |
|||
} |
|||
1839
frontend/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,11 @@ |
|||
import { describe, expect, it } from 'vitest'; |
|||
import { secureTransferOrigin } from './secure-transfer-origin'; |
|||
describe('Secure transfer origin',()=>{ |
|||
it('accepts HTTPS and loopback development, and rejects remote plaintext pages',()=>{ |
|||
expect(secureTransferOrigin({protocol:'https:',hostname:'practice.example'})).toBe(true); |
|||
expect(secureTransferOrigin({protocol:'http:',hostname:'localhost'})).toBe(true); |
|||
expect(secureTransferOrigin({protocol:'http:',hostname:'practice.example'})).toBe(false); |
|||
expect(secureTransferOrigin({protocol:'http:',hostname:'localhost.example'})).toBe(false); |
|||
expect(secureTransferOrigin({protocol:'file:',hostname:''})).toBe(false); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,4 @@ |
|||
/** Never submit an export password from an unencrypted remote page. */ |
|||
export function secureTransferOrigin(origin:Pick<Location,'protocol'|'hostname'>=globalThis.location):boolean { |
|||
return origin.protocol==='https:' || (origin.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(origin.hostname)); |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
<mat-card class="transfer-export"> |
|||
<header><h2>Fahrten und Ausbildung an die Praxisverwaltung übergeben</h2><p>Verschlüsselter Export nur aus deinem angemeldeten Konto. In der Praxisverwaltung unter „Schnell finden → Praktikumsdaten importieren“ die Datei öffnen, Vorschau prüfen und Einträge auswählen.</p></header> |
|||
<p>Für den Datenaustausch beide Anwendungen über HTTPS öffnen.</p> |
|||
@if(!transportSafe){<p role="alert">Bitte die Anwendung über HTTPS öffnen. Der Export ist über eine unverschlüsselte Verbindung gesperrt.</p>} |
|||
<form [formGroup]="form" (ngSubmit)="export()"> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Zeitraum von</mat-label><input matInput type="date" formControlName="from" [readonly]="busy()||!transportSafe"/></mat-form-field> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Zeitraum bis</mat-label><input matInput type="date" formControlName="to" [readonly]="busy()||!transportSafe"/></mat-form-field> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Eigenes Exportpasswort</mat-label><input matInput type="password" autocomplete="new-password" formControlName="password" [readonly]="busy()||!transportSafe"/><mat-hint>Mindestens 12 Zeichen. Kein Kontopasswort verwenden.</mat-hint></mat-form-field> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"><mat-label>Exportpasswort wiederholen</mat-label><input matInput type="password" autocomplete="new-password" formControlName="confirmation" [readonly]="busy()||!transportSafe"/></mat-form-field> |
|||
<div class="full"><mat-checkbox formControlName="include_descriptions">Beschreibungen bewusst mitnehmen</mat-checkbox><p>Standardmäßig werden nur Datum, Kategorie, Minuten, Kilometer und Kosten übertragen. Beschreibungen können persönliche Angaben enthalten. Nicht benötigte Angaben vor dem Export entfernen.</p></div> |
|||
<p class="full">Maximal 500 Einträge je Export. Laufende Zeiterfassungen werden ausgelassen. Kosten werden als Notiz übernommen, ohne Buchung. Nachweise und fachliche Anrechnung anschließend in der Praxisverwaltung prüfen. Passwort und Datei getrennt aufbewahren.</p> |
|||
<button mat-flat-button type="submit" [disabled]="busy()||!transportSafe">Verschlüsselten Praxisexport herunterladen</button> |
|||
</form> |
|||
@if(busy()){<mat-progress-bar mode="indeterminate"/><p role="status">Export wird verschlüsselt …</p>} |
|||
@if(error()){<p role="alert">{{error()}}</p>} |
|||
@if(success()){<p role="status">Export erstellt. Ausgelassene laufende Zeiterfassungen: {{skipped()}}. Die Datei kann jetzt in der Praxisverwaltung eingelesen werden.</p>} |
|||
</mat-card> |
|||
@ -0,0 +1 @@ |
|||
:host{display:block;margin:0 0 24px}.transfer-export{padding:24px;gap:20px}header{border-bottom:1px solid var(--app-border);padding-bottom:16px}h2,p{margin:0}p{line-height:1.6;margin-top:8px}form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:24px}.full{grid-column:1/-1}.full p{font-size:.9rem;color:var(--app-muted)}button{justify-self:start;grid-column:1/-1}@media(max-width:600px){form{grid-template-columns:1fr}.transfer-export{padding:16px}button{width:100%;height:auto;min-height:48px}} |
|||
@ -0,0 +1,30 @@ |
|||
import { provideHttpClient } from '@angular/common/http'; |
|||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; |
|||
import { TestBed } from '@angular/core/testing'; |
|||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; |
|||
import { PracticeTransferExportComponent } from './practice-transfer-export.component'; |
|||
describe('Protected practice export',()=>{ |
|||
let component:PracticeTransferExportComponent;let http:HttpTestingController; |
|||
beforeEach(()=>{ |
|||
TestBed.configureTestingModule({imports:[PracticeTransferExportComponent],providers:[provideHttpClient(),provideHttpClientTesting()]}); |
|||
component=TestBed.createComponent(PracticeTransferExportComponent).componentInstance;http=TestBed.inject(HttpTestingController); |
|||
component.form.patchValue({from:'2024-01-01',to:'2024-12-31',password:'separate-transfer-passphrase',confirmation:'separate-transfer-passphrase'}); |
|||
}); |
|||
afterEach(()=>{http.verify();vi.restoreAllMocks();}); |
|||
it('requires matching passwords and a valid date range before contacting the server',()=>{ |
|||
component.form.controls.confirmation.setValue('different');component.export();http.expectNone('/api/v1/practice_transfer');expect(component.error()).toContain('stimmen nicht'); |
|||
component.form.controls.confirmation.setValue('separate-transfer-passphrase');component.form.controls.to.setValue('2023-01-01');component.export();http.expectNone('/api/v1/practice_transfer'); |
|||
}); |
|||
it('excludes descriptions by default and clears passwords after an encrypted download',()=>{ |
|||
Object.defineProperty(URL,'createObjectURL',{configurable:true,value:vi.fn(()=> 'blob:test')});Object.defineProperty(URL,'revokeObjectURL',{configurable:true,value:vi.fn()}); |
|||
vi.spyOn(HTMLAnchorElement.prototype,'click').mockImplementation(()=>{}); |
|||
component.export();const request=http.expectOne('/api/v1/practice_transfer');expect(request.request.body.include_descriptions).toBe(false);expect(request.request.body.confirmation).toBeUndefined(); |
|||
request.flush(new Blob(['ciphertext']),{headers:{'X-Transfer-Skipped-Running':'2'}}); |
|||
expect(component.skipped()).toBe(2);expect(component.success()).toBe(true);expect(component.form.controls.password.value).toBe('');expect(component.form.controls.confirmation.value).toBe(''); |
|||
}); |
|||
it('retains the form after a rejected export and allows correction',async ()=>{ |
|||
component.export();http.expectOne('/api/v1/practice_transfer').flush(new Blob([JSON.stringify({error:'Nicht angemeldet'})],{type:'application/json'}),{status:401,statusText:'Unauthorized'}); |
|||
await vi.waitFor(()=>expect(component.busy()).toBe(false)); |
|||
expect(component.busy()).toBe(false);expect(component.success()).toBe(false);expect(component.form.controls.password.value).toBe('separate-transfer-passphrase'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,29 @@ |
|||
import { secureTransferOrigin } from '../core/secure-transfer-origin'; |
|||
import { Component, inject, signal } from '@angular/core'; |
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; |
|||
import { MATERIAL } from '../shared/material'; |
|||
@Component({selector:'app-practice-transfer-export',standalone:true,imports:[ReactiveFormsModule,...MATERIAL], |
|||
templateUrl:'./practice-transfer-export.component.html',styleUrl:'./practice-transfer-export.component.scss'}) |
|||
export class PracticeTransferExportComponent { |
|||
private readonly http=inject(HttpClient); |
|||
readonly transportSafe=secureTransferOrigin(); |
|||
readonly busy=signal(false); readonly error=signal(''); readonly success=signal(false); readonly skipped=signal(0); |
|||
readonly form=inject(FormBuilder).nonNullable.group({from:[`${new Date().getFullYear()}-01-01`,Validators.required],to:[new Date().toLocaleDateString('sv-SE'),Validators.required],password:['',[Validators.required,Validators.minLength(12),Validators.maxLength(256)]],confirmation:['',Validators.required],include_descriptions:false}); |
|||
export():void{ |
|||
if(this.busy()||!this.transportSafe)return;this.error.set('');this.success.set(false); |
|||
const value=this.form.getRawValue(); |
|||
if(this.form.invalid){this.form.markAllAsTouched();this.error.set('Bitte Zeitraum und Exportpasswort mit mindestens 12 Zeichen angeben.');return;} |
|||
if(value.password!==value.confirmation){this.error.set('Die Passwörter stimmen nicht überein.');return;} |
|||
if(value.from>value.to){this.error.set('Das Ende muss am oder nach dem Beginn liegen.');return;} |
|||
this.busy.set(true); |
|||
this.http.post('/api/v1/practice_transfer',{from:value.from,to:value.to,transfer_password:value.password,include_descriptions:value.include_descriptions},{observe:'response',responseType:'blob'}).subscribe({ |
|||
next:response=>{ |
|||
const file=response.body;if(file){const url=URL.createObjectURL(file);const link=document.createElement('a');link.href=url;link.download=`praktikum-praxis-${value.from}-${value.to}.pcmtransfer`;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);} |
|||
this.skipped.set(Number(response.headers.get('X-Transfer-Skipped-Running'))||0);this.success.set(true);this.busy.set(false); |
|||
this.form.controls.password.reset();this.form.controls.confirmation.reset(); |
|||
}, |
|||
error:async response=>{let message='Export fehlgeschlagen. Bitte erneut versuchen.';try{const body=response.error instanceof Blob?JSON.parse(await response.error.text()):response.error;if(typeof body?.error==='string')message=body.error;}catch{}this.error.set(message);this.busy.set(false);} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
{ |
|||
"extends": "./tsconfig.json", |
|||
"compilerOptions": { |
|||
"outDir": "./out-tsc/spec", |
|||
"types": [ |
|||
"node" |
|||
] |
|||
}, |
|||
"include": [ |
|||
"src/**/*.spec.ts", |
|||
"src/**/*.d.ts" |
|||
] |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
ENV["RAILS_ENV"] = "test" |
|||
require_relative "../config/environment" |
|||
require "rails/test_help" |
|||
|
|||
class PracticeTransferTest < ActionDispatch::IntegrationTest |
|||
setup do |
|||
@user=User.create!(email:"export@example.test",password:"secure-test-password-123",password_confirmation:"secure-test-password-123",api_token_digest:Digest::SHA256.hexdigest("test-transfer-token")) |
|||
@other=User.create!(email:"other-export@example.test",password:"secure-test-password-123",password_confirmation:"secure-test-password-123") |
|||
@entry=@user.entries.create!(date:"2024-06-15",praktikums_typ:"propädeutikum",entry_art:"Supervision",hours:1,minutes:31,distance_km:20,beschreibung:"Confidential description",kosten:120) |
|||
@other.entries.create!(date:"2024-06-15",praktikums_typ:"propädeutikum",entry_art:"Supervision",hours:9,minutes:0,distance_km:999) |
|||
@password="separate-transfer-passphrase" |
|||
@params={from:"2024-01-01",to:"2024-12-31",transfer_password:@password} |
|||
end |
|||
test "missing and non bearer tokens never select a user without a token" do |
|||
post "/api/v1/practice_transfer",params:@params |
|||
assert_response :unauthorized |
|||
post "/api/v1/practice_transfer",params:@params,headers:{"Authorization"=>"Basic test-transfer-token"} |
|||
assert_response :unauthorized |
|||
end |
|||
test "exports only own records, excludes descriptions by default and retains source identities" do |
|||
post "/api/v1/practice_transfer",params:@params,headers:{"Authorization"=>"Bearer test-transfer-token"} |
|||
assert_response :success |
|||
assert_includes response.headers["Cache-Control"],"no-store" |
|||
payload=ProfessionalTransferEnvelope.decrypt(response.body,@password) |
|||
assert_equal 2,payload["records"].length |
|||
assert_equal [@entry.id.to_s],payload["records"].map { |r| r["source_id"] }.uniq |
|||
assert payload["records"].all? { |r| r["description"].nil? } |
|||
assert payload["records"].all? { |r| r["duration_minutes"]==91 } |
|||
namespace=payload["source_namespace"] |
|||
post "/api/v1/practice_transfer",params:@params.merge(include_descriptions:true),headers:{"Authorization"=>"Bearer test-transfer-token"} |
|||
second=ProfessionalTransferEnvelope.decrypt(response.body,@password) |
|||
assert_equal namespace,second["source_namespace"] |
|||
assert_equal "Confidential description",second["records"].first["description"] |
|||
end |
|||
test "envelope rejects tampering and incorrect passwords" do |
|||
content=ProfessionalTransferEnvelope.encrypt({"value"=>42},@password) |
|||
assert_raises(ProfessionalTransferEnvelope::Invalid) { ProfessionalTransferEnvelope.decrypt(content,"wrong-transfer-password") } |
|||
envelope=JSON.parse(content);envelope["tag"]=Base64.strict_encode64("0"*16) |
|||
assert_raises(ProfessionalTransferEnvelope::Invalid) { ProfessionalTransferEnvelope.decrypt(JSON.generate(envelope),@password) } |
|||
end |
|||
end |
|||
Loading…
Reference in new issue