diff --git a/connect/docs/M365-03-PRUEFPROTOKOLL.md b/connect/docs/M365-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..afaec0c5 --- /dev/null +++ b/connect/docs/M365-03-PRUEFPROTOKOLL.md @@ -0,0 +1,40 @@ +# M365-03 – Prüfprotokoll: Outlook-Kalender-Kopplung (Fristen/Wiedervorlage) + +Datum: 2026-09-28. Host: Entwicklungshost 192.168.1.204, eigene Wegwerf-DB/-Rolle `m365_03_test` (angelegt für diesen Testlauf, keine Kollision mit anderen parallelen Agenten). Frischer Klon von `origin/main` in eigenem Wegwerf-Arbeitsverzeichnis. + +## Umfang + +- `connect/internal/m365calsync`: Kopplung von NEXARCH-Fristen/Wiedervorlagen mit dem Outlook-Kalender über Microsoft Graph (M365-01, `m365connector.Client.Send`). Push-gerichtetes Gegenstück zu `m365docsync` (M365-02): NEXARCH ist die führende Seite, Outlook folgt — statt Konflikterkennung reconciled `Store.Sync` den Outlook-Kalender auf den aktuellen NEXARCH-Stand. + - `Store.Sync`: neue Frist/Wiedervorlage -> Kalendereintrag in Outlook angelegt (AC1). Geänderte Frist (Titel/Beschreibung/Fälligkeit) -> bestehender Eintrag aktualisiert statt dupliziert (AC2). Frist nicht mehr in der Quelle vorhanden -> Kalendereintrag gelöscht und Tracking-Datensatz entfernt (AC3). Änderungserkennung über einen Inhalts-Hash je Frist, analog zum Hash-Vergleich in `m365docsync`. + - `GraphTarget`: rendert `CalendarEvent` in Microsoft Graphs Kalenderereignis-Format und ruft `POST/PATCH/DELETE /users/{id}/events(/{eventId})` über die neue generische Schreib-Primitive `m365connector.Client.Send` auf. + - `m365connector.Client.Send`: kleine Erweiterung von M365-01 um eine generische Schreib-Operation (POST/PATCH/DELETE) neben der bestehenden `Read` (GET) — gleiche Fehlerbehandlung (`ErrPermissionExpired`/`ErrConnectionFailed`), keine Sonderbehandlung für Microsoft 365. +- Migration `0012_m365_calendar_events`: Tabelle `m365_calendar_events` mit Fälligkeit, Titel, Inhalts-Hash und der von Graph zurückgegebenen Ereignis-Id je Frist/Wiedervorlage. +- `connect/scripts/check-security-test-coverage.sh`: `internal/m365calsync` in die Liste sicherheitsrelevanter Pakete mit Testpflicht aufgenommen (Tenant-gescopte Daten). + +## Limitation: kein echter Microsoft-Testmandant + +Wie bereits bei M365-01/M365-02 dokumentiert stehen in dieser Umgebung keine echten Azure-AD/Microsoft-365-Testmandanten-Credentials zur Verfügung. Die Graph-Kalender-Aufrufe (`POST/PATCH/DELETE /users/{id}/events`) werden gegen einen `httptest.Server`-Mock geprüft, der den realen Antwortvertrag nachbildet (JSON-Event-Body, von Graph vergebene Ereignis-Id, 204 bei Löschung). Ein echter End-to-End-Lauf gegen einen produktiven Microsoft-Testmandanten bzw. ein echtes Outlook-Postfach ist damit **offen** und ehrlich als solcher dokumentiert, nicht übersprungen. Die "Prüfungen vor der Abnahme" unten sind entsprechend gegen das Mock-Postfach durchgeführt, nicht gegen ein echtes Test-Outlook. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Frist/Wiedervorlage erscheint als Kalendereintrag in Outlook | **Erfüllt.** `TestSync_NewDeadline_CreatesCalendarEvent` (neuer Kalendereintrag mit korrektem Titel wird angelegt, Tracking-Datensatz mit Graph-Ereignis-Id gespeichert) + `TestGraphTarget_CreateEvent_PostsToGraphEventsEndpoint` (echter HTTP-Mock-Aufruf gegen `POST /users/{id}/events`). | +| 2 | Änderung der Frist in NEXARCH aktualisiert den Kalendereintrag | **Erfüllt.** `TestSync_ChangedDeadline_UpdatesExistingCalendarEvent` (geänderte Fälligkeit löst Update auf denselben Eintrag aus, kein Duplikat) + `TestGraphTarget_UpdateEvent_PatchesExistingEvent` (echter HTTP-Mock-Aufruf gegen `PATCH .../events/{id}`). `TestSync_UnchangedDeadline_IsNoop` als Gegenprobe: unveränderte Frist löst keinen weiteren Push aus. | +| 3 | Gelöschte Frist entfernt den zugehörigen Kalendereintrag | **Erfüllt.** `TestSync_DeletedDeadline_RemovesCalendarEvent` (Frist verschwindet aus der Quelle -> Kalendereintrag gelöscht, Tracking-Datensatz entfernt) + `TestGraphTarget_DeleteEvent_DeletesExistingEvent` (echter HTTP-Mock-Aufruf gegen `DELETE .../events/{id}`). | + +## Prüfungen vor der Abnahme + +1. **Testfrist im Test-Outlook-Kalender korrekt sichtbar.** Zielwert: nach `Sync` existiert ein Kalendereintrag mit dem Titel und der Fälligkeit der Frist, plus ein `m365_calendar_events`-Datensatz mit der von Graph vergebenen Ereignis-Id. Ergebnis: **bestanden** (gegen das Mock-Postfach, siehe Limitation oben) — `TestSync_NewDeadline_CreatesCalendarEvent`, `TestGraphTarget_CreateEvent_PostsToGraphEventsEndpoint`. +2. **Friständerung im Test korrekt im Kalender nachgezogen.** Zielwert: eine geänderte Fälligkeit/Titel führt zu einem `PATCH` auf denselben Graph-Ereignis-Eintrag, kein zweiter Eintrag entsteht. Ergebnis: **bestanden** — `TestSync_ChangedDeadline_UpdatesExistingCalendarEvent`, `TestGraphTarget_UpdateEvent_PatchesExistingEvent`. +3. **Löschung im Test entfernt Kalendereintrag vollständig.** Zielwert: eine aus der Quelle verschwundene Frist führt zu `DELETE` auf den zugehörigen Graph-Ereignis-Eintrag und Entfernen des Tracking-Datensatzes. Ergebnis: **bestanden** — `TestSync_DeletedDeadline_RemovesCalendarEvent`, `TestGraphTarget_DeleteEvent_DeletesExistingEvent`. + +## Regressionsprüfung + +`go build ./...`, `go vet ./...` für das gesamte `connect`-Modul: keine Fehler. `go test ./internal/m365calsync/... -v -p 1` (gegen `TEST_TENANT_DSN` der Wegwerf-DB `m365_03_test`): alle 7 Tests grün. Zusätzlich `go test ./internal/m365connector/... ./internal/m365docsync/...` erneut ausgeführt, um die Erweiterung von `m365connector.Client` um `Send` nicht zu regressieren: alle bestehenden Tests weiterhin grün. `scripts/check-security-test-coverage.sh`: Merge-Gate bestanden, `internal/m365calsync` mit Testpflicht erfasst. + +## Nicht Bestandteil dieser Kachel (nicht umgesetzt) + +- `QA-03` (Prüfgate ERP/DATEV/M365) — laut Ticket ausdrücklich eine andere Kachel; die Schnittstelle (`Store`, `Source`, `Target`, klar typisierte Fehler) ist so gehalten, dass ein späteres Prüfgate ohne Umbau darauf aufbauen kann. +- Anbindung an die konkreten Datenquellen TSK-02 (Fristen-Kalenderlogik) und VOR-03 (Wiedervorlage-Akte-Objekt-Terminverknüpfung): `Source` ist die Schnittstelle, über die ein Aufrufer die aktuellen Fristen/Wiedervorlagen liefert (gleiches Muster wie `m365docsync.Source` für SharePoint/OneDrive) — welches Modul sie konkret implementiert und mit welchem Scheduling (`syncjob`, analog zu M365-02s `Runner`) ist nicht Teil dieser Kachel, da im Ticket nicht als Akzeptanzkriterium gefordert. +- Automatischer Hintergrund-Dispatcher/Scheduler für `Sync` — gleiche dokumentierte Lücke wie bei `m365docsync.Runner`, `masterdatasync.Runner` und `erpexport.Store.ClaimDueRetries`. diff --git a/connect/internal/m365calsync/graphtarget.go b/connect/internal/m365calsync/graphtarget.go new file mode 100644 index 00000000..c3df8b3b --- /dev/null +++ b/connect/internal/m365calsync/graphtarget.go @@ -0,0 +1,106 @@ +package m365calsync + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" +) + +// GraphSender is the minimal capability GraphTarget needs from +// m365connector.Client: an authenticated raw Graph call, read or write. +// Kept as an interface (rather than importing *m365connector.Client +// directly) so the dependency stays the same direction as m365docsync's +// GraphReader. +type GraphSender interface { + Send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) +} + +// GraphTarget pushes Deadline/Wiedervorlage events into one user's Outlook +// calendar via Microsoft Graph's calendar-event endpoints, and implements +// Target for Sync. +type GraphTarget struct { + Graph GraphSender + Secret credential.Secret + UserID string +} + +func (g GraphTarget) eventsPath() string { + return "/users/" + g.UserID + "/events" +} + +func (g GraphTarget) eventPath(externalEventID string) string { + return g.eventsPath() + "/" + externalEventID +} + +type graphDateTimeTimeZone struct { + DateTime string `json:"dateTime"` + TimeZone string `json:"timeZone"` +} + +type graphEventBody struct { + Subject string `json:"subject"` + Body struct { + ContentType string `json:"contentType"` + Content string `json:"content"` + } `json:"body"` + Start graphDateTimeTimeZone `json:"start"` + End graphDateTimeTimeZone `json:"end"` +} + +func toGraphEvent(e CalendarEvent) graphEventBody { + var out graphEventBody + out.Subject = e.Subject + out.Body.ContentType = "text" + out.Body.Content = e.Body + out.Start = graphDateTimeTimeZone{DateTime: e.Start.UTC().Format(time.RFC3339), TimeZone: "UTC"} + out.End = graphDateTimeTimeZone{DateTime: e.End.UTC().Format(time.RFC3339), TimeZone: "UTC"} + return out +} + +// CreateEvent creates a new Outlook calendar event via +// POST /users/{id}/events and returns Graph's event id (AC1). +func (g GraphTarget) CreateEvent(ctx context.Context, e CalendarEvent) (string, error) { + payload, err := json.Marshal(toGraphEvent(e)) + if err != nil { + return "", fmt.Errorf("m365calsync: create event: encode: %w", err) + } + respBody, err := g.Graph.Send(ctx, g.Secret, "POST", g.eventsPath(), payload) + if err != nil { + return "", fmt.Errorf("m365calsync: create event: %w", err) + } + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(respBody, &created); err != nil { + return "", fmt.Errorf("m365calsync: create event: decode response: %w", err) + } + if created.ID == "" { + return "", fmt.Errorf("m365calsync: create event: empty id in response") + } + return created.ID, nil +} + +// UpdateEvent updates an existing Outlook calendar event via +// PATCH /users/{id}/events/{eventId} (AC2). +func (g GraphTarget) UpdateEvent(ctx context.Context, externalEventID string, e CalendarEvent) error { + payload, err := json.Marshal(toGraphEvent(e)) + if err != nil { + return fmt.Errorf("m365calsync: update event: encode: %w", err) + } + if _, err := g.Graph.Send(ctx, g.Secret, "PATCH", g.eventPath(externalEventID), payload); err != nil { + return fmt.Errorf("m365calsync: update event: %w", err) + } + return nil +} + +// DeleteEvent removes an Outlook calendar event via +// DELETE /users/{id}/events/{eventId} (AC3). +func (g GraphTarget) DeleteEvent(ctx context.Context, externalEventID string) error { + if _, err := g.Graph.Send(ctx, g.Secret, "DELETE", g.eventPath(externalEventID), nil); err != nil { + return fmt.Errorf("m365calsync: delete event: %w", err) + } + return nil +} diff --git a/connect/internal/m365calsync/graphtarget_test.go b/connect/internal/m365calsync/graphtarget_test.go new file mode 100644 index 00000000..6b39a4f2 --- /dev/null +++ b/connect/internal/m365calsync/graphtarget_test.go @@ -0,0 +1,127 @@ +package m365calsync + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" +) + +// mockGraphCalendar simulates Microsoft Graph's calendar-event endpoints, +// since a real Microsoft-365-Testmandant is not available in this +// environment (same documented limitation as M365-01/M365-02, see +// connect/docs/M365-03-PRUEFPROTOKOLL.md). +func mockGraphCalendar(t *testing.T) (*httptest.Server, *[]string) { + t.Helper() + var calls []string + mux := http.NewServeMux() + mux.HandleFunc("/users/user-1/events", func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var body graphEventBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":"AAMk-graph-event-1","subject":"` + body.Subject + `"}`)) + }) + mux.HandleFunc("/users/user-1/events/AAMk-graph-event-1", func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + switch r.Method { + case http.MethodPatch: + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"AAMk-graph-event-1"}`)) + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, &calls +} + +// httpGraphSender calls the mock server directly, in the same +// method/path/body shape m365connector.Client.Send exposes (already +// covered by M365-01's own tests, not re-tested here). +type httpGraphSender struct{ baseURL string } + +func (s httpGraphSender) Send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) { + var reqBody io.Reader + if body != nil { + reqBody = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, s.baseURL+path, reqBody) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// Pruefung 1 / AC1: CreateEvent legt ueber Graph einen neuen +// Kalendereintrag an und liefert dessen Graph-Id zurueck. +func TestGraphTarget_CreateEvent_PostsToGraphEventsEndpoint(t *testing.T) { + srv, calls := mockGraphCalendar(t) + target := GraphTarget{Graph: httpGraphSender{baseURL: srv.URL}, Secret: credential.Secret{AccessToken: "tok"}, UserID: "user-1"} + + id, err := target.CreateEvent(context.Background(), CalendarEvent{ + Subject: "Frist Testmandant", Body: "Testfrist", Start: time.Now(), End: time.Now(), + }) + if err != nil { + t.Fatalf("CreateEvent() = %v, want nil", err) + } + if id != "AAMk-graph-event-1" { + t.Fatalf("id = %q, want AAMk-graph-event-1", id) + } + if len(*calls) != 1 || (*calls)[0] != "POST /users/user-1/events" { + t.Fatalf("calls = %v, want single POST to events endpoint", *calls) + } +} + +// Pruefung 2 / AC2: UpdateEvent aktualisiert den bestehenden Eintrag ueber +// PATCH auf dessen Graph-Id statt einen neuen anzulegen. +func TestGraphTarget_UpdateEvent_PatchesExistingEvent(t *testing.T) { + srv, calls := mockGraphCalendar(t) + target := GraphTarget{Graph: httpGraphSender{baseURL: srv.URL}, Secret: credential.Secret{AccessToken: "tok"}, UserID: "user-1"} + + err := target.UpdateEvent(context.Background(), "AAMk-graph-event-1", CalendarEvent{ + Subject: "Frist verschoben", Body: "", Start: time.Now(), End: time.Now(), + }) + if err != nil { + t.Fatalf("UpdateEvent() = %v, want nil", err) + } + if len(*calls) != 1 || (*calls)[0] != "PATCH /users/user-1/events/AAMk-graph-event-1" { + t.Fatalf("calls = %v, want single PATCH to the event", *calls) + } +} + +// Pruefung 3 / AC3: DeleteEvent entfernt den Kalendereintrag ueber DELETE +// auf dessen Graph-Id. +func TestGraphTarget_DeleteEvent_DeletesExistingEvent(t *testing.T) { + srv, calls := mockGraphCalendar(t) + target := GraphTarget{Graph: httpGraphSender{baseURL: srv.URL}, Secret: credential.Secret{AccessToken: "tok"}, UserID: "user-1"} + + if err := target.DeleteEvent(context.Background(), "AAMk-graph-event-1"); err != nil { + t.Fatalf("DeleteEvent() = %v, want nil", err) + } + if len(*calls) != 1 || (*calls)[0] != "DELETE /users/user-1/events/AAMk-graph-event-1" { + t.Fatalf("calls = %v, want single DELETE to the event", *calls) + } +} diff --git a/connect/internal/m365calsync/m365calsync.go b/connect/internal/m365calsync/m365calsync.go new file mode 100644 index 00000000..2ab4743e --- /dev/null +++ b/connect/internal/m365calsync/m365calsync.go @@ -0,0 +1,261 @@ +// Package m365calsync implements M365-03: coupling of NEXARCH deadlines +// and Wiedervorlagen (recall dates) with the Outlook calendar over the +// Microsoft-Graph connector (M365-01). Push-directed counterpart to +// m365docsync (M365-02): NEXARCH is the leading side here, Outlook +// follows, so instead of a hash-conflict model, Sync creates, updates and +// deletes the Outlook event to match the current NEXARCH state. +// +// AC1: a deadline/Wiedervorlage unseen so far gets a new Outlook calendar +// event created for it. +// AC2: a changed deadline (title/description/due time) updates the +// existing Outlook event instead of creating a duplicate. +// AC3: a deadline no longer present in the source (deleted in NEXARCH) +// has its Outlook event deleted and its tracking record removed. +package m365calsync + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("m365calsync: not found") + +// Ref identifies the connector instance a set of synced calendar events +// belongs to. +type Ref struct { + TenantSlug string + ConnectorName string + ConnectorInstanceID string +} + +// Deadline is one NEXARCH-side Frist/Wiedervorlage as read from its +// owning module (TSK-02/VOR-03), the minimal fields needed to render an +// Outlook calendar event. +type Deadline struct { + DeadlineID string + Title string + Description string + DueAt time.Time +} + +func (d Deadline) contentHash() string { + sum := sha256.Sum256([]byte(d.Title + "\x00" + d.Description + "\x00" + d.DueAt.UTC().Format(time.RFC3339))) + return hex.EncodeToString(sum[:]) +} + +// Source lists every currently active deadline/Wiedervorlage that should +// have a matching Outlook event. A deadline no longer returned here is +// treated as deleted (AC3). +type Source interface { + ListDeadlines(ctx context.Context) ([]Deadline, error) +} + +// CalendarEvent is the Graph-facing rendering of a Deadline. +type CalendarEvent struct { + Subject string + Body string + Start time.Time + End time.Time +} + +// Target is the minimal capability Sync needs to push calendar events to +// Outlook. GraphTarget implements it via Microsoft Graph's calendar-event +// endpoints. +type Target interface { + CreateEvent(ctx context.Context, e CalendarEvent) (externalEventID string, err error) + UpdateEvent(ctx context.Context, externalEventID string, e CalendarEvent) error + DeleteEvent(ctx context.Context, externalEventID string) error +} + +// Record is the stored sync state for one deadline's Outlook event. +type Record struct { + ID int64 + Ref Ref + DeadlineID string + ExternalEventID string + Title string + DueAt time.Time + ContentHash string +} + +// Result summarizes one Sync call. +type Result struct { + Created int + Updated int + Deleted int +} + +// Store persists calendar sync state in Postgres. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Get returns the stored record for one deadline, or ErrNotFound. +func (s *Store) Get(ctx context.Context, ref Ref, deadlineID string) (Record, error) { + var r Record + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_slug, connector_name, connector_instance_id, deadline_id, + external_event_id, title, due_at, content_hash + FROM m365_calendar_events + WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3 AND deadline_id = $4 + `, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID, deadlineID).Scan( + &r.ID, &r.Ref.TenantSlug, &r.Ref.ConnectorName, &r.Ref.ConnectorInstanceID, &r.DeadlineID, + &r.ExternalEventID, &r.Title, &r.DueAt, &r.ContentHash, + ) + if errors.Is(err, pgx.ErrNoRows) { + return Record{}, ErrNotFound + } + if err != nil { + return Record{}, fmt.Errorf("m365calsync: get: %w", err) + } + return r, nil +} + +// ListByRef returns every stored record for a connector instance, for +// inspection/testing. +func (s *Store) ListByRef(ctx context.Context, ref Ref) ([]Record, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_slug, connector_name, connector_instance_id, deadline_id, + external_event_id, title, due_at, content_hash + FROM m365_calendar_events + WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3 + ORDER BY deadline_id + `, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID) + if err != nil { + return nil, fmt.Errorf("m365calsync: list: %w", err) + } + defer rows.Close() + var out []Record + for rows.Next() { + var r Record + if err := rows.Scan(&r.ID, &r.Ref.TenantSlug, &r.Ref.ConnectorName, &r.Ref.ConnectorInstanceID, &r.DeadlineID, + &r.ExternalEventID, &r.Title, &r.DueAt, &r.ContentHash); err != nil { + return nil, fmt.Errorf("m365calsync: list: scan: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// Sync fetches every current deadline from source and reconciles the +// Outlook calendar to match: +// - unseen deadline_id -> a new Outlook event is created (AC1). +// - known deadline_id, content changed since the last sync -> the +// existing Outlook event is updated (AC2). +// - known deadline_id, content unchanged -> no-op. +// - stored record whose deadline_id is no longer in source's listing -> +// the Outlook event is deleted and the tracking record removed (AC3). +func (s *Store) Sync(ctx context.Context, ref Ref, source Source, target Target) (Result, error) { + deadlines, err := source.ListDeadlines(ctx) + if err != nil { + return Result{}, fmt.Errorf("m365calsync: sync: list deadlines: %w", err) + } + + var res Result + seen := make(map[string]bool, len(deadlines)) + for _, d := range deadlines { + seen[d.DeadlineID] = true + outcome, err := s.syncOne(ctx, ref, d, target) + if err != nil { + return res, fmt.Errorf("m365calsync: sync %s: %w", d.DeadlineID, err) + } + switch outcome { + case outcomeCreated: + res.Created++ + case outcomeUpdated: + res.Updated++ + } + } + + stored, err := s.ListByRef(ctx, ref) + if err != nil { + return res, fmt.Errorf("m365calsync: sync: list stored: %w", err) + } + for _, rec := range stored { + if seen[rec.DeadlineID] { + continue + } + if err := s.deleteOne(ctx, ref, rec, target); err != nil { + return res, fmt.Errorf("m365calsync: sync: delete %s: %w", rec.DeadlineID, err) + } + res.Deleted++ + } + return res, nil +} + +type syncOutcome int + +const ( + outcomeNoop syncOutcome = iota + outcomeCreated + outcomeUpdated +) + +func (s *Store) syncOne(ctx context.Context, ref Ref, d Deadline, target Target) (syncOutcome, error) { + hash := d.contentHash() + event := CalendarEvent{Subject: d.Title, Body: d.Description, Start: d.DueAt, End: d.DueAt} + + existing, err := s.Get(ctx, ref, d.DeadlineID) + if errors.Is(err, ErrNotFound) { + externalEventID, createErr := target.CreateEvent(ctx, event) + if createErr != nil { + return outcomeNoop, fmt.Errorf("create event: %w", createErr) + } + _, insertErr := s.pool.Exec(ctx, ` + INSERT INTO m365_calendar_events + (tenant_slug, connector_name, connector_instance_id, deadline_id, + external_event_id, title, due_at, content_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID, d.DeadlineID, + externalEventID, d.Title, d.DueAt, hash) + if insertErr != nil { + return outcomeNoop, fmt.Errorf("insert: %w", insertErr) + } + return outcomeCreated, nil + } + if err != nil { + return outcomeNoop, fmt.Errorf("lookup: %w", err) + } + + if existing.ContentHash == hash { + return outcomeNoop, nil + } + + if err := target.UpdateEvent(ctx, existing.ExternalEventID, event); err != nil { + return outcomeNoop, fmt.Errorf("update event: %w", err) + } + _, err = s.pool.Exec(ctx, ` + UPDATE m365_calendar_events + SET title = $2, due_at = $3, content_hash = $4, updated_at = now() + WHERE id = $1 + `, existing.ID, d.Title, d.DueAt, hash) + if err != nil { + return outcomeNoop, fmt.Errorf("update record: %w", err) + } + return outcomeUpdated, nil +} + +func (s *Store) deleteOne(ctx context.Context, ref Ref, rec Record, target Target) error { + if err := target.DeleteEvent(ctx, rec.ExternalEventID); err != nil { + return fmt.Errorf("delete event: %w", err) + } + _, err := s.pool.Exec(ctx, ` + DELETE FROM m365_calendar_events + WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3 AND deadline_id = $4 + `, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID, rec.DeadlineID) + if err != nil { + return fmt.Errorf("delete record: %w", err) + } + return nil +} diff --git a/connect/internal/m365calsync/m365calsync_test.go b/connect/internal/m365calsync/m365calsync_test.go new file mode 100644 index 00000000..d0e71edc --- /dev/null +++ b/connect/internal/m365calsync/m365calsync_test.go @@ -0,0 +1,209 @@ +package m365calsync + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb" +) + +func newStore(t *testing.T) (*Store, *pgxpool.Pool) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, dsn) + + root, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatalf("migrationsverzeichnis: %v", err) + } + migrations, err := migrate.Load(root) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return NewStore(pool), pool +} + +var testRef = Ref{TenantSlug: "acme", ConnectorName: "m365", ConnectorInstanceID: "inst-1"} + +// deadlineSource serves a fixed slice of deadlines, so a test can change +// what "NEXARCH currently has" between Sync calls. +type deadlineSource struct{ deadlines []Deadline } + +func (s *deadlineSource) ListDeadlines(ctx context.Context) ([]Deadline, error) { + return s.deadlines, nil +} + +// fakeTarget records every Create/Update/Delete call instead of talking to +// Graph, so Store.Sync's reconciliation logic can be tested independently +// of GraphTarget's HTTP mapping (which has its own test). +type fakeTarget struct { + nextID int + created []CalendarEvent + updated map[string]CalendarEvent + deleted map[string]bool +} + +func newFakeTarget() *fakeTarget { + return &fakeTarget{updated: map[string]CalendarEvent{}, deleted: map[string]bool{}} +} + +func (f *fakeTarget) CreateEvent(ctx context.Context, e CalendarEvent) (string, error) { + f.nextID++ + f.created = append(f.created, e) + return fmt.Sprintf("evt-%d", f.nextID), nil +} + +func (f *fakeTarget) UpdateEvent(ctx context.Context, externalEventID string, e CalendarEvent) error { + f.updated[externalEventID] = e + return nil +} + +func (f *fakeTarget) DeleteEvent(ctx context.Context, externalEventID string) error { + f.deleted[externalEventID] = true + return nil +} + +func mustTime(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("zeit parsen: %v", err) + } + return ts +} + +// AC1 / Pruefung 1: eine neue Frist/Wiedervorlage erscheint nach Sync als +// Kalendereintrag in Outlook. +func TestSync_NewDeadline_CreatesCalendarEvent(t *testing.T) { + store, _ := newStore(t) + target := newFakeTarget() + src := &deadlineSource{deadlines: []Deadline{ + {DeadlineID: "frist-1", Title: "Einspruchsfrist Mandant Muster", Description: "Frist laeuft ab", + DueAt: mustTime(t, "2026-10-15T09:00:00Z")}, + }} + + res, err := store.Sync(context.Background(), testRef, src, target) + if err != nil { + t.Fatalf("sync: %v", err) + } + if res.Created != 1 || res.Updated != 0 || res.Deleted != 0 { + t.Fatalf("erwartet 1 created, bekam %+v", res) + } + if len(target.created) != 1 || target.created[0].Subject != "Einspruchsfrist Mandant Muster" { + t.Fatalf("kalenderereignis nicht korrekt erzeugt: %+v", target.created) + } + + rec, err := store.Get(context.Background(), testRef, "frist-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if rec.ExternalEventID != "evt-1" || rec.Title != "Einspruchsfrist Mandant Muster" { + t.Fatalf("record nicht korrekt gespeichert: %+v", rec) + } +} + +// AC2 / Pruefung 2: eine geaenderte Frist aktualisiert den bestehenden +// Kalendereintrag statt einen zweiten anzulegen. +func TestSync_ChangedDeadline_UpdatesExistingCalendarEvent(t *testing.T) { + store, _ := newStore(t) + target := newFakeTarget() + src := &deadlineSource{deadlines: []Deadline{ + {DeadlineID: "frist-1", Title: "Klageerwiderung", Description: "", + DueAt: mustTime(t, "2026-10-15T09:00:00Z")}, + }} + + if _, err := store.Sync(context.Background(), testRef, src, target); err != nil { + t.Fatalf("erster sync: %v", err) + } + + src.deadlines[0].DueAt = mustTime(t, "2026-10-20T09:00:00Z") + res, err := store.Sync(context.Background(), testRef, src, target) + if err != nil { + t.Fatalf("zweiter sync: %v", err) + } + if res.Created != 0 || res.Updated != 1 || res.Deleted != 0 { + t.Fatalf("erwartet 1 updated, bekam %+v", res) + } + if len(target.created) != 1 { + t.Fatalf("es darf kein zweites ereignis angelegt werden, erzeugt: %+v", target.created) + } + if _, ok := target.updated["evt-1"]; !ok { + t.Fatalf("bestehendes ereignis wurde nicht aktualisiert") + } + + rec, err := store.Get(context.Background(), testRef, "frist-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if !rec.DueAt.Equal(mustTime(t, "2026-10-20T09:00:00Z")) { + t.Fatalf("faelligkeit nicht uebernommen: %v", rec.DueAt) + } +} + +// Gegenprobe zu AC2: unveraenderte Frist erzeugt keinen weiteren Push. +func TestSync_UnchangedDeadline_IsNoop(t *testing.T) { + store, _ := newStore(t) + target := newFakeTarget() + src := &deadlineSource{deadlines: []Deadline{ + {DeadlineID: "frist-1", Title: "Wiedervorlage Vertragsablauf", Description: "", + DueAt: mustTime(t, "2026-11-01T08:00:00Z")}, + }} + + if _, err := store.Sync(context.Background(), testRef, src, target); err != nil { + t.Fatalf("erster sync: %v", err) + } + res, err := store.Sync(context.Background(), testRef, src, target) + if err != nil { + t.Fatalf("zweiter sync: %v", err) + } + if res.Created != 0 || res.Updated != 0 || res.Deleted != 0 { + t.Fatalf("erwartet keine aenderung, bekam %+v", res) + } + if len(target.updated) != 0 { + t.Fatalf("unveraenderte frist darf kein update ausloesen") + } +} + +// AC3 / Pruefung 3: eine geloeschte Frist entfernt den Kalendereintrag +// vollstaendig. +func TestSync_DeletedDeadline_RemovesCalendarEvent(t *testing.T) { + store, _ := newStore(t) + target := newFakeTarget() + src := &deadlineSource{deadlines: []Deadline{ + {DeadlineID: "frist-1", Title: "Termin geloescht", Description: "", + DueAt: mustTime(t, "2026-11-05T10:00:00Z")}, + }} + + if _, err := store.Sync(context.Background(), testRef, src, target); err != nil { + t.Fatalf("erster sync: %v", err) + } + + src.deadlines = nil + res, err := store.Sync(context.Background(), testRef, src, target) + if err != nil { + t.Fatalf("zweiter sync: %v", err) + } + if res.Deleted != 1 { + t.Fatalf("erwartet 1 deleted, bekam %+v", res) + } + if !target.deleted["evt-1"] { + t.Fatalf("kalenderereignis wurde nicht geloescht") + } + if _, err := store.Get(context.Background(), testRef, "frist-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("record haette entfernt werden muessen, get() = %v", err) + } +} diff --git a/connect/internal/m365connector/m365connector.go b/connect/internal/m365connector/m365connector.go index 3c159b51..5755fb2a 100644 --- a/connect/internal/m365connector/m365connector.go +++ b/connect/internal/m365connector/m365connector.go @@ -143,25 +143,44 @@ func (c *Client) Read(ctx context.Context, secret credential.Secret, path string return c.get(ctx, secret, path) } +// Send performs a write call (POST/PATCH/DELETE) against a Graph path +// with the given JSON body, returning the raw response body. Generic +// write primitive alongside Read, used by M365-03's calendar-event push +// (M365-02 only needed reads). +func (c *Client) Send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) { + return c.send(ctx, secret, method, path, body) +} + func (c *Client) get(ctx context.Context, secret credential.Secret, path string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.graphURL()+path, nil) + return c.send(ctx, secret, http.MethodGet, path, nil) +} + +func (c *Client) send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + bodyReader = strings.NewReader(string(body)) + } + req, err := http.NewRequestWithContext(ctx, method, c.cfg.graphURL()+path, bodyReader) if err != nil { return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err) } req.Header.Set("Authorization", "Bearer "+secret.AccessToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(body)) + return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(respBody)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body)) + return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(respBody)) } - return body, nil + return respBody, nil } diff --git a/connect/migrations/0012_m365_calendar_events.down.sql b/connect/migrations/0012_m365_calendar_events.down.sql new file mode 100644 index 00000000..c860253c --- /dev/null +++ b/connect/migrations/0012_m365_calendar_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS m365_calendar_events; diff --git a/connect/migrations/0012_m365_calendar_events.up.sql b/connect/migrations/0012_m365_calendar_events.up.sql new file mode 100644 index 00000000..2212aa30 --- /dev/null +++ b/connect/migrations/0012_m365_calendar_events.up.sql @@ -0,0 +1,23 @@ +-- M365-03: Kopplung von NEXARCH-Fristen/Wiedervorlagen mit dem +-- Outlook-Kalender. Haelt je Frist/Wiedervorlage den zugehoerigen +-- Graph-Kalenderereignis-Id sowie einen Inhalts-Hash, um eine Aenderung +-- gegenueber dem zuletzt gepushten Stand zu erkennen (AC2) -- analog zum +-- Hash-Vergleich in m365_doc_records (M365-02), hier aber push- statt +-- pull-gerichtet: NEXARCH ist die fuehrende Seite, Outlook folgt. +CREATE TABLE m365_calendar_events ( + id BIGSERIAL PRIMARY KEY, + tenant_slug TEXT NOT NULL, + connector_name TEXT NOT NULL, + connector_instance_id TEXT NOT NULL, + deadline_id TEXT NOT NULL, + external_event_id TEXT NOT NULL, + title TEXT NOT NULL, + due_at TIMESTAMPTZ NOT NULL, + content_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_slug, connector_name, connector_instance_id, deadline_id) +); + +CREATE INDEX m365_calendar_events_ref_idx + ON m365_calendar_events (tenant_slug, connector_name, connector_instance_id); diff --git a/connect/scripts/check-security-test-coverage.sh b/connect/scripts/check-security-test-coverage.sh index f430a1fd..ad0efe71 100755 --- a/connect/scripts/check-security-test-coverage.sh +++ b/connect/scripts/check-security-test-coverage.sh @@ -22,6 +22,7 @@ SECURITY_PACKAGES=( "internal/fieldmapping" "internal/masterdatasync" "internal/m365docsync" + "internal/m365calsync" "internal/syncjob" "internal/statusapi" )