+
+ Für den Datenaustausch beide Anwendungen über HTTPS öffnen.
+ @if(!transportSafe){Bitte die Anwendung über HTTPS öffnen. Der Export ist über eine unverschlüsselte Verbindung gesperrt.
}
+
+ @if(busy()){Export wird verschlüsselt …
}
+ @if(error()){{{error()}}
}
+ @if(success()){Export erstellt. Ausgelassene laufende Zeiterfassungen: {{skipped()}}. Die Datei kann jetzt in der Praxisverwaltung eingelesen werden.
}
+
diff --git a/frontend/src/app/pages/practice-transfer-export.component.scss b/frontend/src/app/pages/practice-transfer-export.component.scss
new file mode 100644
index 0000000..2f2b61b
--- /dev/null
+++ b/frontend/src/app/pages/practice-transfer-export.component.scss
@@ -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}}
diff --git a/frontend/src/app/pages/practice-transfer-export.component.spec.ts b/frontend/src/app/pages/practice-transfer-export.component.spec.ts
new file mode 100644
index 0000000..e0c24fd
--- /dev/null
+++ b/frontend/src/app/pages/practice-transfer-export.component.spec.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/app/pages/practice-transfer-export.component.ts b/frontend/src/app/pages/practice-transfer-export.component.ts
new file mode 100644
index 0000000..cb66f76
--- /dev/null
+++ b/frontend/src/app/pages/practice-transfer-export.component.ts
@@ -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);}
+ });
+ }
+}
diff --git a/frontend/src/app/pages/reports.component.ts b/frontend/src/app/pages/reports.component.ts
index 0788dd8..501acdb 100644
--- a/frontend/src/app/pages/reports.component.ts
+++ b/frontend/src/app/pages/reports.component.ts
@@ -1,3 +1,4 @@
+import { PracticeTransferExportComponent } from './practice-transfer-export.component';
import { Component, OnInit, signal } from "@angular/core";
import { DatePipe } from "@angular/common";
import { MATERIAL } from "../shared/material";
@@ -12,7 +13,7 @@ interface Row {
@Component({
standalone: true,
- imports: [DatePipe, ...MATERIAL],
+ imports: [PracticeTransferExportComponent, DatePipe, ...MATERIAL],
template: `
@@ -20,6 +21,7 @@ interface Row {
Geleistete Zeiten nach Monat und Kategorie
+
@if (loading()) { }
@for (group of groups(); track group.month) {
diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json
new file mode 100644
index 0000000..e38c997
--- /dev/null
+++ b/frontend/tsconfig.spec.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": [
+ "node"
+ ]
+ },
+ "include": [
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/test/practice_transfer_test.rb b/test/practice_transfer_test.rb
new file mode 100644
index 0000000..3a5a812
--- /dev/null
+++ b/test/practice_transfer_test.rb
@@ -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