From e2a121ecd114820cb2525edf422be6961963f120 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 12 Aug 2026 07:38:14 +0200 Subject: [PATCH] feat(events): add real-time event watching - stream authenticated oCIS SSE events with filtering and bounded retries - add readable output, max-wait controls, and shell completion - document event semantics and cover protocol and application behavior Signed-off-by: Matteo --- .agents/skills/use-ocis-cli/SKILL.md | 4 + .../use-ocis-cli/references/commands.md | 14 + .claude/skills/use-ocis-cli/SKILL.md | 4 + .../use-ocis-cli/references/commands.md | 14 + ARCHITECTURE.md | 8 +- Makefile | 2 +- README.md | 42 +++ internal/app/app_test.go | 4 +- internal/app/doctor.go | 8 + internal/app/event_api.go | 32 ++ internal/app/event_service.go | 329 ++++++++++++++++++ internal/app/event_service_test.go | 249 +++++++++++++ internal/app/runtime.go | 15 + internal/command/event.go | 65 ++++ internal/command/root.go | 1 + internal/command/root_test.go | 53 +++ internal/eventstream/client.go | 163 +++++++++ internal/eventstream/client_test.go | 162 +++++++++ internal/eventstream/types.go | 46 +++ internal/sharing/client.go | 7 + internal/sharing/client_test.go | 4 +- 21 files changed, 1221 insertions(+), 5 deletions(-) create mode 100644 internal/app/event_api.go create mode 100644 internal/app/event_service.go create mode 100644 internal/app/event_service_test.go create mode 100644 internal/command/event.go create mode 100644 internal/eventstream/client.go create mode 100644 internal/eventstream/client_test.go create mode 100644 internal/eventstream/types.go diff --git a/.agents/skills/use-ocis-cli/SKILL.md b/.agents/skills/use-ocis-cli/SKILL.md index 811afdf..e77d972 100644 --- a/.agents/skills/use-ocis-cli/SKILL.md +++ b/.agents/skills/use-ocis-cli/SKILL.md @@ -64,6 +64,10 @@ protocol-level work. - Use `activity list` for read-only account-wide or resource-scoped history. Pass a remote path or `--space` when the requested scope is narrower than the account. +- Use `event watch` only when the user wants future real-time events. It does + not replay missed events; use `--jsonl` for automation and `--once` for a + single event. Add `--max-wait DURATION` with `--once` when the agent must not + wait indefinitely. - Use `notification list` and `notification info` to inspect unread events. In oCIS, `notification dismiss` is the server's mark-as-read operation; it does not delete the resource referenced by the notification. diff --git a/.agents/skills/use-ocis-cli/references/commands.md b/.agents/skills/use-ocis-cli/references/commands.md index 2d0d873..e8efea0 100644 --- a/.agents/skills/use-ocis-cli/references/commands.md +++ b/.agents/skills/use-ocis-cli/references/commands.md @@ -129,6 +129,20 @@ remote path to use the current file root, or pass `--space SPACE` to scope the query to that Space. Use `--depth`, `--limit`, and `--sort` for server-side filtering. Activity history is read-only. +## Real-time events + +| Command | Purpose | +| --- | --- | +| `event watch` | Watch future events visible to the authenticated user until interrupted. | +| `event watch --type TYPE` | Show only selected event names; repeat the flag or comma-separate values. | +| `event watch --once` | Exit after the first matching event. | +| `event watch --once --max-wait DURATION` | Wait for one matching event without hanging indefinitely. | +| `event types` | List event names known by this CLI. | + +Use `--jsonl`, not `--json`, for the open-ended stream. SSE has no replay, so +use `activity list` or `notification list` when retained state is required. +Never describe `event watch` as a lossless audit log. + ## Notifications | Command | Purpose | diff --git a/.claude/skills/use-ocis-cli/SKILL.md b/.claude/skills/use-ocis-cli/SKILL.md index 811afdf..e77d972 100644 --- a/.claude/skills/use-ocis-cli/SKILL.md +++ b/.claude/skills/use-ocis-cli/SKILL.md @@ -64,6 +64,10 @@ protocol-level work. - Use `activity list` for read-only account-wide or resource-scoped history. Pass a remote path or `--space` when the requested scope is narrower than the account. +- Use `event watch` only when the user wants future real-time events. It does + not replay missed events; use `--jsonl` for automation and `--once` for a + single event. Add `--max-wait DURATION` with `--once` when the agent must not + wait indefinitely. - Use `notification list` and `notification info` to inspect unread events. In oCIS, `notification dismiss` is the server's mark-as-read operation; it does not delete the resource referenced by the notification. diff --git a/.claude/skills/use-ocis-cli/references/commands.md b/.claude/skills/use-ocis-cli/references/commands.md index 2d0d873..e8efea0 100644 --- a/.claude/skills/use-ocis-cli/references/commands.md +++ b/.claude/skills/use-ocis-cli/references/commands.md @@ -129,6 +129,20 @@ remote path to use the current file root, or pass `--space SPACE` to scope the query to that Space. Use `--depth`, `--limit`, and `--sort` for server-side filtering. Activity history is read-only. +## Real-time events + +| Command | Purpose | +| --- | --- | +| `event watch` | Watch future events visible to the authenticated user until interrupted. | +| `event watch --type TYPE` | Show only selected event names; repeat the flag or comma-separate values. | +| `event watch --once` | Exit after the first matching event. | +| `event watch --once --max-wait DURATION` | Wait for one matching event without hanging indefinitely. | +| `event types` | List event names known by this CLI. | + +Use `--jsonl`, not `--json`, for the open-ended stream. SSE has no replay, so +use `activity list` or `notification list` when retained state is required. +Never describe `event watch` as a lossless audit log. + ## Notifications | Command | Purpose | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4bc7282..9f6aec8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,6 +16,7 @@ internal/ auth/ OIDC protocol implementation config/ persisted profile model and atomic storage credentials/ OS credential-service adapter + eventstream/ authenticated server-sent-events protocol client federation/ ScienceMesh OCM invitation and connection client graph/ LibreGraph Spaces, directory, and permission client httpapi/ authenticated retrying HTTP transport @@ -59,7 +60,7 @@ without starting a subprocess. `batch_service.go`, `filesystem_service.go`, `filesystem_tree_service.go`, `filesystem_du_service.go`, `filesystem_touch_service.go`, `filesystem_walk.go`, `metadata_service.go`, - `activity_service.go`, `notification_service.go`, + `activity_service.go`, `event_service.go`, `notification_service.go`, `share_overview_service.go`, `space_member_service.go`, `space_update_service.go`, `space_lifecycle_service.go`, and @@ -80,6 +81,9 @@ without starting a subprocess. protected resumable-upload locations in separate size-bounded entries in macOS Keychain, Linux Secret Service, or Windows Credential Manager; no plaintext or legacy-format migration path exists. +- `internal/eventstream`: open one authenticated oCIS SSE connection, validate + its media type, and decode bounded standard SSE fields. Reconnect and output + policy remain in the application layer. - `internal/federation`: create, list, and accept ScienceMesh invitation tokens and list or remove accepted OCM user connections. It has no profile, persistence, Cobra, or resource-sharing policy of its own. @@ -140,7 +144,7 @@ without starting a subprocess. scalar custom-property `PROPFIND`/`PROPPATCH` operations. Protocol-specific behavior belongs in dedicated `internal/activities`, `internal/auth`, -`internal/federation`, `internal/graph`, `internal/notifications`, +`internal/eventstream`, `internal/federation`, `internal/graph`, `internal/notifications`, `internal/search`, `internal/sharing`, `internal/trash`, `internal/versions`, and `internal/webdav` adapters. Recursive local/remote traversal belongs in `internal/transfer`. diff --git a/Makefile b/Makefile index b47ac7c..b99aa63 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ check: fmt coverage: go run ./tools/covercheck -min $(COVERAGE_MIN) \ - activities app auth federation graph httpapi notifications retry search sharing sync trash transfer versions \ + activities app auth eventstream federation graph httpapi notifications retry search sharing sync trash transfer versions \ webdav fmt: diff --git a/README.md b/README.md index 7cac272..f4e01e9 100644 --- a/README.md +++ b/README.md @@ -1242,6 +1242,48 @@ have permission to list grants on the selected resource, so access can differ between users and Spaces. The CLI reports that authorization decision instead of assuming that every authenticated user can inspect every activity. +## Real-time events + +Watch events delivered to the authenticated user as they happen: + +```sh +ocis event watch +ocis event watch --type userlog-notification +ocis event watch --type share-created --type share-removed +ocis event watch --once +ocis event watch --type postprocessing-finished --once --max-wait 30s +ocis --jsonl event watch +``` + +`--type` filters locally and can be repeated or given a comma-separated list. +`--once` exits after the first matching event, which is useful in scripts. +Combine `--once` with `--max-wait DURATION` to avoid waiting indefinitely when +no matching event arrives. Shell completion suggests known event names, but +manually entered names remain accepted for compatibility with newer servers. +`ocis event types` lists the event names known by this CLI; a server may add +other names without requiring a CLI update. The list includes a short +description for each known type. + +Human mode reports when the connection is ready, explains what it is watching, +and shows reconnect progress on stderr. Events on stdout contain the UTC +receive time, a readable description, and the useful fields actually sent by +oCIS. File events currently carry stable item and Space IDs rather than remote +paths, so those values are labeled explicitly. Notification events show the +server's subject and message. A watch is an open-ended stream, so `--json` is +rejected; use `--jsonl` for the complete payload in one versioned JSON envelope +per event. Press Ctrl-C to stop cleanly. Unexpected disconnects use the global +bounded `--retries` policy. Unlike ordinary commands, a watch has no overall +HTTP timeout once connected. + +oCIS SSE streams contain only future events and do not replay events missed +before the command started or while it was disconnected. Use `activity list` +for retained file/Space history and `notification list` for the current unread +userlog. A `backchannel-logout` event ends the watch with an authentication +error; the CLI does not silently delete the saved profile or credentials. + +The command first checks the server's `core.support-sse` capability. Event +availability and visibility remain server-controlled and user-specific. + ## Notifications List and inspect the authenticated user's unread in-app notifications: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a151391..065f1b2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -301,6 +301,7 @@ func TestDoctorValidatesProfileAndCapabilities(t *testing.T) { switch { case request.URL.Path == "/ocs/v2.php/cloud/capabilities": writeAppOCS(writer, `{"capabilities":{ + "core":{"support-sse":true}, "files_sharing":{"api_enabled":true,"public":{ "enabled":true,"password":{"enforced":false}, "expire_date":{"enabled":true} @@ -336,7 +337,8 @@ func TestDoctorValidatesProfileAndCapabilities(t *testing.T) { } if !strings.Contains(rendered.String(), `"type": "diagnostic"`) || !strings.Contains(rendered.String(), `"DAV capabilities"`) || - !strings.Contains(rendered.String(), `"public links"`) { + !strings.Contains(rendered.String(), `"public links"`) || + !strings.Contains(rendered.String(), `"real-time events"`) { t.Fatalf("output: %s", rendered.String()) } } diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 0d0f0d9..de4dcc1 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -97,6 +97,14 @@ func RunDoctorWithOptions( Name: "resumable uploads", Status: tusStatus, Detail: resumableUploadCapabilityDetail(features), }) + eventStatus := "unsupported" + if features.Core.SupportSSE { + eventStatus = "ok" + } + checks = append(checks, DoctorCheck{ + Name: "real-time events", Status: eventStatus, + Detail: "core.support-sse", + }) if _, err := client.stat("/"); err != nil { return classifyProtocolError("check DAV authentication", err) } diff --git a/internal/app/event_api.go b/internal/app/event_api.go new file mode 100644 index 0000000..bef9cb9 --- /dev/null +++ b/internal/app/event_api.go @@ -0,0 +1,32 @@ +package app + +import ( + "context" + "time" +) + +// EventWatchRequest selects real-time events to print. +type EventWatchRequest struct { + Types []string + Once bool + MaxWait time.Duration +} + +// RunEventWatchWithOptions watches authenticated real-time server events. +func RunEventWatchWithOptions( + ctx context.Context, + request EventWatchRequest, + selectedProfile string, + options RunOptions, +) error { + return classifyProtocolError( + "event watch", + runEventWatch(ctx, request, selectedProfile, options.normalized()), + ) +} + +// RunEventTypesWithOptions prints the event names known by this CLI. The +// server may emit additional names. +func RunEventTypesWithOptions(options RunOptions) error { + return runEventTypes(options.normalized()) +} diff --git a/internal/app/event_service.go b/internal/app/event_service.go new file mode 100644 index 0000000..dfa1fab --- /dev/null +++ b/internal/app/event_service.go @@ -0,0 +1,329 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/mzner/ocis-cli/internal/apperror" + "github.com/mzner/ocis-cli/internal/eventstream" + appoutput "github.com/mzner/ocis-cli/internal/output" + "github.com/mzner/ocis-cli/internal/retry" +) + +const eventRetryWait = 200 * time.Millisecond + +type watchedEvent struct { + Type string `json:"type"` + Data any `json:"data"` + ID string `json:"id,omitempty"` + ReceivedAt string `json:"receivedAt"` +} + +func runEventTypes(options RunOptions) error { + types := eventstream.KnownTypes() + if options.OutputMode != appoutput.Human { + return writeOutput(options, "event-type", types) + } + writer := tabwriter.NewWriter(options.Out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(writer, "TYPE\tDESCRIPTION"); err != nil { + return err + } + for _, eventType := range types { + if _, err := fmt.Fprintf( + writer, "%s\t%s\n", eventType.Name, eventType.Description, + ); err != nil { + return err + } + } + return writer.Flush() +} + +func runEventWatch( + ctx context.Context, + request EventWatchRequest, + selectedProfile string, + options RunOptions, +) error { + if options.OutputMode == appoutput.JSON { + return apperror.Wrap( + apperror.KindUsage, "event watch", + errors.New("--json cannot represent an open-ended stream; use --jsonl"), + ) + } + if request.MaxWait < 0 { + return apperror.Wrap( + apperror.KindUsage, "event watch", + errors.New("--max-wait cannot be negative"), + ) + } + if request.MaxWait > 0 && !request.Once { + return apperror.Wrap( + apperror.KindUsage, "event watch", + errors.New("--max-wait requires --once"), + ) + } + selectedTypes, err := eventTypeFilter(request.Types) + if err != nil { + return apperror.Wrap(apperror.KindUsage, "event watch", err) + } + client, err := newClientWithOptions(ctx, selectedProfile, options) + if err != nil { + return err + } + capabilities, err := client.sharingClient().Capabilities(ctx) + if err != nil { + return fmt.Errorf("check real-time event support: %w", err) + } + if !capabilities.Core.SupportSSE { + return errors.New( + "server does not advertise real-time event support (core.support-sse)", + ) + } + watchCtx := ctx + if request.MaxWait > 0 { + var cancel context.CancelFunc + watchCtx, cancel = context.WithTimeout(ctx, request.MaxWait) + defer cancel() + } + + attempt := 0 + connection := 0 + for { + received := false + onceComplete := false + loggedOut := false + var handlerErr error + serverDelay := time.Duration(0) + stop := errors.New("stop event stream") + err := client.eventStreamClient().Watch( + watchCtx, func() { + connection++ + writeEventConnectionStatus( + client, request, options, connection > 1, + ) + }, func(event eventstream.Event) error { + received = true + if event.Retry > 0 { + serverDelay = event.Retry + } + if event.Type == "backchannel-logout" { + loggedOut = true + return stop + } + if len(selectedTypes) > 0 && !selectedTypes[event.Type] { + return nil + } + if err := writeWatchedEvent(event, options); err != nil { + handlerErr = err + return stop + } + if request.Once { + onceComplete = true + return stop + } + return nil + }, + ) + switch { + case handlerErr != nil: + return handlerErr + case onceComplete: + return nil + case loggedOut: + return apperror.Wrap( + apperror.KindAuthentication, "event watch", + errors.New("the server ended this login session; run ocis auth login again"), + ) + case errors.Is(err, context.Canceled), + errors.Is(err, context.DeadlineExceeded): + return eventWatchContextError(ctx, request, err) + case errors.Is(err, stop): + return nil + } + if received { + attempt = 0 + } + if attempt >= options.Retries { + if err == nil { + err = errors.New("server closed the event stream") + } + return fmt.Errorf("event stream disconnected: %w", err) + } + writeEventReconnectStatus(options, attempt+1, options.Retries) + client.logger.Debug( + "reconnecting event stream", "attempt", attempt+2, + ) + if err := retry.Wait( + watchCtx, eventRetryWait, attempt, serverDelay, + ); err != nil { + return eventWatchContextError(ctx, request, err) + } + attempt++ + } +} + +func eventWatchContextError( + parent context.Context, request EventWatchRequest, err error, +) error { + if errors.Is(err, context.DeadlineExceeded) && parent.Err() == nil && + request.MaxWait > 0 { + return fmt.Errorf( + "no matching event received within %s", request.MaxWait, + ) + } + return err +} + +func writeEventConnectionStatus( + client *client, + request EventWatchRequest, + options RunOptions, + reconnected bool, +) { + if options.OutputMode != appoutput.Human { + return + } + if reconnected { + _, _ = fmt.Fprintln(options.Err, "Reconnected.") + return + } + host := client.profile.Server + if serverURL, err := url.Parse(client.profile.Server); err == nil && + serverURL.Host != "" { + host = serverURL.Host + } + _, _ = fmt.Fprintf(options.Err, "Connected to %s.\n", host) + selected := "all events" + if len(request.Types) > 0 { + values := append([]string(nil), request.Types...) + sort.Strings(values) + selected = strings.Join(values, ", ") + } + if request.Once { + if request.MaxWait > 0 { + _, _ = fmt.Fprintf( + options.Err, + "Waiting up to %s for the first matching event (%s). Press Ctrl-C to stop.\n", + request.MaxWait, selected, + ) + return + } + _, _ = fmt.Fprintf( + options.Err, + "Waiting for the first matching event (%s). Press Ctrl-C to stop.\n", + selected, + ) + return + } + _, _ = fmt.Fprintf( + options.Err, "Watching %s. Press Ctrl-C to stop.\n", selected, + ) +} + +func writeEventReconnectStatus(options RunOptions, attempt, maximum int) { + if options.OutputMode == appoutput.Human { + _, _ = fmt.Fprintf( + options.Err, "Connection lost. Reconnecting (%d/%d)...\n", + attempt, maximum, + ) + } +} + +func eventTypeFilter(values []string) (map[string]bool, error) { + result := make(map[string]bool, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, errors.New("--type cannot be empty") + } + result[value] = true + } + return result, nil +} + +func writeWatchedEvent(event eventstream.Event, options RunOptions) error { + data := any(event.Data) + var structured any + if json.Valid([]byte(event.Data)) && + json.Unmarshal([]byte(event.Data), &structured) == nil { + data = structured + } + value := watchedEvent{ + Type: event.Type, Data: data, ID: event.ID, + ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + if options.OutputMode == appoutput.JSONL { + return (appoutput.Renderer{ + Writer: options.Out, Mode: appoutput.JSONL, Type: "event", + }).WriteJSONL(value) + } + _, err := fmt.Fprintf( + options.Out, "%s %s %s\n", + time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), + eventstream.Description(value.Type), eventHumanDetail(value.Data), + ) + return err +} + +func eventHumanDetail(data any) string { + fields, ok := data.(map[string]any) + if !ok { + return compactEventData(data) + } + if subject := eventString(fields, "subject"); subject != "" { + if message := eventString(fields, "message"); message != "" && + message != subject { + return subject + " - " + message + } + return subject + } + parts := make([]string, 0, 4) + for _, field := range []struct { + key string + label string + }{ + {key: "itemid", label: "item ID"}, + {key: "spaceid", label: "Space ID"}, + {key: "initiatorid", label: "initiator ID"}, + {key: "userid", label: "user ID"}, + } { + if value := eventString(fields, field.key); value != "" { + parts = append(parts, field.label+": "+value) + } + } + if affected, ok := fields["affecteduserids"].([]any); ok && len(affected) > 0 { + values := make([]string, 0, len(affected)) + for _, value := range affected { + if text, ok := value.(string); ok && text != "" { + values = append(values, text) + } + } + if len(values) > 0 { + parts = append(parts, "affected user IDs: "+strings.Join(values, ", ")) + } + } + if len(parts) > 0 { + return strings.Join(parts, "; ") + } + return compactEventData(data) +} + +func eventString(fields map[string]any, key string) string { + value, _ := fields[key].(string) + return strings.TrimSpace(value) +} + +func compactEventData(data any) string { + payload, err := json.Marshal(data) + if err != nil { + return fmt.Sprint(data) + } + return string(payload) +} diff --git a/internal/app/event_service_test.go b/internal/app/event_service_test.go new file mode 100644 index 0000000..0157e38 --- /dev/null +++ b/internal/app/event_service_test.go @@ -0,0 +1,249 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mzner/ocis-cli/internal/apperror" + "github.com/mzner/ocis-cli/internal/eventstream" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +func TestEventWatchOnceFiltersAndWritesJSONL(t *testing.T) { + var connections atomic.Int32 + server := newEventTestServer(t, true, func(writer http.ResponseWriter) { + connections.Add(1) + _, _ = io.WriteString(writer, "event: file-touched\n") + _, _ = io.WriteString(writer, "data: {\"path\":\"/ignored.txt\"}\n\n") + _, _ = io.WriteString(writer, "event: share-created\n") + _, _ = io.WriteString(writer, "id: event-2\n") + _, _ = io.WriteString(writer, "data: {\"path\":\"/report.txt\"}\n\n") + }) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + + var output bytes.Buffer + var diagnostics bytes.Buffer + err := RunEventWatchWithOptions( + context.Background(), EventWatchRequest{ + Types: []string{"share-created"}, Once: true, + }, "", RunOptions{ + Out: &output, Err: &diagnostics, OutputMode: appoutput.JSONL, + }, + ) + if err != nil { + t.Fatal(err) + } + var envelope appoutput.Envelope + if err := json.Unmarshal(output.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Type != "event" || + !strings.Contains(output.String(), `"type":"share-created"`) || + !strings.Contains(output.String(), `"path":"/report.txt"`) || + strings.Contains(output.String(), "ignored.txt") || connections.Load() != 1 { + t.Fatalf("connections=%d output=%q", connections.Load(), output.String()) + } + if diagnostics.Len() != 0 { + t.Fatalf("JSONL diagnostics: %q", diagnostics.String()) + } +} + +func TestEventWatchReconnects(t *testing.T) { + var connections atomic.Int32 + server := newEventTestServer(t, true, func(writer http.ResponseWriter) { + if connections.Add(1) == 1 { + return + } + _, _ = io.WriteString(writer, "event: folder-created\n") + _, _ = io.WriteString(writer, "data: {\"name\":\"Reports\"}\n\n") + }) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + + var output bytes.Buffer + var diagnostics bytes.Buffer + err := RunEventWatchWithOptions( + context.Background(), EventWatchRequest{Once: true}, "", + RunOptions{Out: &output, Err: &diagnostics, Retries: 1}, + ) + if err != nil || connections.Load() != 2 || + !strings.Contains(output.String(), "Folder created") || + !strings.Contains(diagnostics.String(), "Connection lost") || + !strings.Contains(diagnostics.String(), "Reconnected") { + t.Fatalf( + "connections=%d output=%q diagnostics=%q error=%v", + connections.Load(), output.String(), diagnostics.String(), err, + ) + } +} + +func TestEventWatchMaxWaitBoundsOnce(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + switch request.URL.Path { + case "/ocs/v2.php/cloud/capabilities": + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"ocs":{"meta":{ + "status":"ok","statuscode":200,"message":"OK" + },"data":{"capabilities":{"core":{"support-sse":true}}}}}`) + case "/ocs/v2.php/apps/notifications/api/v1/notifications/sse": + writer.Header().Set("Content-Type", "text/event-stream") + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + <-request.Context().Done() + default: + t.Fatalf("unexpected request: %s", request.URL.Path) + } + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + err := RunEventWatchWithOptions( + context.Background(), EventWatchRequest{ + Once: true, MaxWait: 25 * time.Millisecond, + }, "", RunOptions{Out: io.Discard, Err: io.Discard}, + ) + if err == nil || !strings.Contains(err.Error(), "within 25ms") { + t.Fatalf("error: %v", err) + } +} + +func TestEventWatchValidatesOutputAndCapability(t *testing.T) { + t.Setenv("OCIS_CONFIG", filepath.Join(t.TempDir(), "missing", "config.json")) + err := RunEventWatchWithOptions( + context.Background(), EventWatchRequest{}, "", + RunOptions{Out: io.Discard, OutputMode: appoutput.JSON}, + ) + if !apperror.IsKind(err, apperror.KindUsage) || + !strings.Contains(err.Error(), "--jsonl") { + t.Fatalf("JSON error: %v", err) + } + for _, request := range []EventWatchRequest{ + {Once: true, MaxWait: -time.Second}, + {MaxWait: time.Second}, + } { + err = RunEventWatchWithOptions( + context.Background(), request, "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindUsage) { + t.Fatalf("request=%#v error=%v", request, err) + } + } + + server := newEventTestServer(t, false, func(http.ResponseWriter) { + t.Fatal("unsupported server must not receive an SSE request") + }) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + err = RunEventWatchWithOptions( + context.Background(), EventWatchRequest{}, "", RunOptions{Out: io.Discard}, + ) + if err == nil || !strings.Contains(err.Error(), "core.support-sse") { + t.Fatalf("capability error: %v", err) + } +} + +func TestEventWatchTreatsBackchannelLogoutAsAuthenticationFailure(t *testing.T) { + server := newEventTestServer(t, true, func(writer http.ResponseWriter) { + _, _ = io.WriteString(writer, "event: backchannel-logout\n") + _, _ = io.WriteString(writer, "data: {}\n\n") + }) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + err := RunEventWatchWithOptions( + context.Background(), EventWatchRequest{}, "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindAuthentication) || + !strings.Contains(err.Error(), "login session") { + t.Fatalf("error: %v", err) + } +} + +func TestEventTypesOutput(t *testing.T) { + var output bytes.Buffer + if err := RunEventTypesWithOptions(RunOptions{Out: &output}); err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "userlog-notification", "file-touched", "backchannel-logout", + } { + if !strings.Contains(output.String(), expected) { + t.Fatalf("missing %q in %q", expected, output.String()) + } + } +} + +func TestWatchedEventHumanOutputUsesReadableServerFields(t *testing.T) { + tests := []struct { + event eventstream.Event + want []string + }{ + { + event: eventstream.Event{ + Type: "file-touched", + Data: `{"itemid":"storage$space!file","spaceid":"storage$space"}`, + }, + want: []string{ + "File changed", "item ID: storage$space!file", + "Space ID: storage$space", + }, + }, + { + event: eventstream.Event{ + Type: "userlog-notification", + Data: `{"subject":"Resource shared","message":"Alice shared report.pdf with you"}`, + }, + want: []string{ + "Unread notification received", "Resource shared", + "Alice shared report.pdf with you", + }, + }, + } + for _, test := range tests { + var output bytes.Buffer + if err := writeWatchedEvent( + test.event, RunOptions{Out: &output}.normalized(), + ); err != nil { + t.Fatal(err) + } + for _, expected := range test.want { + if !strings.Contains(output.String(), expected) { + t.Fatalf("missing %q in %q", expected, output.String()) + } + } + } +} + +func newEventTestServer( + t *testing.T, supportSSE bool, stream func(http.ResponseWriter), +) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + switch request.URL.Path { + case "/ocs/v2.php/cloud/capabilities": + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"ocs":{"meta":{ + "status":"ok","statuscode":200,"message":"OK" + },"data":{"capabilities":{"core":{"support-sse":`+ + strconv.FormatBool(supportSSE)+`}}}}}`) + case "/ocs/v2.php/apps/notifications/api/v1/notifications/sse": + writer.Header().Set("Content-Type", "text/event-stream") + stream(writer) + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index fe0eb60..9e06b68 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -14,6 +14,7 @@ import ( "github.com/mzner/ocis-cli/internal/auth" appconfig "github.com/mzner/ocis-cli/internal/config" "github.com/mzner/ocis-cli/internal/credentials" + "github.com/mzner/ocis-cli/internal/eventstream" "github.com/mzner/ocis-cli/internal/federation" "github.com/mzner/ocis-cli/internal/graph" "github.com/mzner/ocis-cli/internal/httpapi" @@ -40,6 +41,7 @@ type client struct { store *store ctx context.Context activities *activities.Client + events *eventstream.Client dav *webdav.Client graph *graph.Client search *search.Client @@ -61,6 +63,19 @@ func (client *client) activitiesClient() *activities.Client { return client.activities } +func (client *client) eventStreamClient() *eventstream.Client { + if client.events == nil { + config := client.apiConfig() + // The application owns reconnects for a long-lived stream. Disabling the + // generic request retries avoids two overlapping retry loops. + config.Retries = 0 + client.events = eventstream.NewClient( + config, httpClientFor(client.profile, 0), + ) + } + return client.events +} + func (client *client) apiConfig() httpapi.Config { return httpapi.Config{ Server: client.profile.Server, Username: client.profile.Username, diff --git a/internal/command/event.go b/internal/command/event.go new file mode 100644 index 0000000..11c82e1 --- /dev/null +++ b/internal/command/event.go @@ -0,0 +1,65 @@ +package command + +import ( + "time" + + "github.com/mzner/ocis-cli/internal/app" + "github.com/mzner/ocis-cli/internal/eventstream" + "github.com/spf13/cobra" +) + +func newEventCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "event", Aliases: []string{"events"}, + Short: "Watch real-time server events", + } + command.AddCommand( + newEventWatchCommand(options), + &cobra.Command{ + Use: "types", Short: "List event names known by this CLI", Args: exactArgs(0), + RunE: func(command *cobra.Command, _ []string) error { + return app.RunEventTypesWithOptions(options.runOptions(command)) + }, + }, + ) + return command +} + +func newEventWatchCommand(options *globalOptions) *cobra.Command { + var eventTypes []string + var once bool + var maxWait time.Duration + command := &cobra.Command{ + Use: "watch", Short: "Watch future events until interrupted", Args: exactArgs(0), + RunE: func(command *cobra.Command, _ []string) error { + return app.RunEventWatchWithOptions( + command.Context(), app.EventWatchRequest{ + Types: eventTypes, Once: once, MaxWait: maxWait, + }, options.profile, options.runOptions(command), + ) + }, + } + command.Flags().StringSliceVar( + &eventTypes, "type", nil, + "show only this event type (repeat or comma-separate)", + ) + command.Flags().BoolVar( + &once, "once", false, "exit after the first matching event", + ) + command.Flags().DurationVar( + &maxWait, "max-wait", 0, + "stop waiting for the first matching event after this duration", + ) + _ = command.RegisterFlagCompletionFunc( + "type", func( + _ *cobra.Command, _ []string, _ string, + ) ([]string, cobra.ShellCompDirective) { + values := make([]string, 0, len(eventstream.KnownTypes())) + for _, eventType := range eventstream.KnownTypes() { + values = append(values, eventType.Name+"\t"+eventType.Description) + } + return values, cobra.ShellCompDirectiveNoFileComp + }, + ) + return command +} diff --git a/internal/command/root.go b/internal/command/root.go index 9fbf35d..541ae88 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -129,6 +129,7 @@ func NewRootCommand() *cobra.Command { newFederationCommand(options), newNotificationCommand(options), newActivityCommand(options), + newEventCommand(options), newSearchCommand(options), newSyncCommand(options), newTagCommand(options), diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 3d72d0b..5e81958 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/mzner/ocis-cli/internal/apperror" + "github.com/spf13/cobra" ) func TestRootCommandMetadata(t *testing.T) { @@ -72,6 +73,7 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { "federation, federated, ocm", "notification, notifications", "activity, activities", + "event, events", } { if !strings.Contains(help, expected) { t.Fatalf("help does not contain %q:\n%s", expected, help) @@ -79,6 +81,57 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { } } +func TestEventCommandsAndAliasesAreDiscoverable(t *testing.T) { + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"event", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, expected := range []string{"watch", "types"} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("event help missing %q:\n%s", expected, output.String()) + } + } + + output.Reset() + root = NewRootCommand() + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"event", "watch", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, expected := range []string{"--type", "--once", "--max-wait"} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("event watch help missing %q:\n%s", expected, output.String()) + } + } + watch, _, err := root.Find([]string{"event", "watch"}) + if err != nil { + t.Fatal(err) + } + complete, found := watch.GetFlagCompletionFunc("type") + if !found { + t.Fatal("--type completion is not registered") + } + values, directive := complete(watch, nil, "file") + if directive != cobra.ShellCompDirectiveNoFileComp || + !strings.Contains(strings.Join(values, "\n"), "file-touched\tFile changed") { + t.Fatalf("completion values=%v directive=%v", values, directive) + } + for _, args := range [][]string{ + {"events", "watch"}, {"event", "types"}, + } { + root = NewRootCommand() + if _, _, err := root.Find(args); err != nil { + t.Fatalf("%v: %v", args, err) + } + } +} + func TestActivityCommandsAndAliasesAreDiscoverable(t *testing.T) { root := NewRootCommand() var output bytes.Buffer diff --git a/internal/eventstream/client.go b/internal/eventstream/client.go new file mode 100644 index 0000000..d6b3a1a --- /dev/null +++ b/internal/eventstream/client.go @@ -0,0 +1,163 @@ +// Package eventstream implements the authenticated oCIS server-sent-events +// protocol. It owns one connection at a time; reconnect policy belongs to the +// application service that knows the user's retry settings. +package eventstream + +import ( + "bufio" + "context" + "errors" + "fmt" + "mime" + "net/http" + "strconv" + "strings" + "time" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +const ( + endpoint = "/ocs/v2.php/apps/notifications/api/v1/notifications/sse" + maxLineBytes = 1 << 20 + maxEventData = 4 << 20 +) + +// Event is one decoded server-sent event. +type Event struct { + Type string + Data string + ID string + Retry time.Duration +} + +// Client opens authenticated SSE connections. +type Client struct { + api *httpapi.Client +} + +// NewClient constructs an event stream client. +func NewClient(config httpapi.Config, httpClient *http.Client) *Client { + return &Client{api: httpapi.NewClient(config, httpClient)} +} + +// Watch opens one SSE connection and invokes handle for each complete event. +// It returns when the connection closes, the context is canceled, or handle +// returns an error. +func (client *Client) Watch( + ctx context.Context, connected func(), handle func(Event) error, +) error { + headers := make(http.Header) + headers.Set("Accept", "text/event-stream") + headers.Set("Cache-Control", "no-cache") + response, err := client.api.Do( + ctx, http.MethodGet, endpoint, nil, headers, + ) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < http.StatusOK || + response.StatusCode >= http.StatusMultipleChoices { + return httpapi.ResponseError(response) + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "text/event-stream") { + return fmt.Errorf( + "event stream returned content type %q, want text/event-stream", + response.Header.Get("Content-Type"), + ) + } + if connected != nil { + connected() + } + return decode(ctx, response, handle) +} + +func decode( + ctx context.Context, + response *http.Response, + handle func(Event) error, +) error { + scanner := bufio.NewScanner(response.Body) + scanner.Buffer(make([]byte, 4096), maxLineBytes) + var event Event + retryDelay := time.Duration(0) + data := make([]string, 0, 1) + dataBytes := 0 + dispatch := func() error { + if len(data) == 0 { + event.Type = "" + event.ID = "" + return nil + } + event.Data = strings.Join(data, "\n") + event.Retry = retryDelay + if event.Type == "" { + event.Type = "message" + } + if err := handle(event); err != nil { + return err + } + event = Event{} + data = data[:0] + dataBytes = 0 + return nil + } + for scanner.Scan() { + if err := ctx.Err(); err != nil { + return err + } + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + if err := dispatch(); err != nil { + return err + } + continue + } + if strings.HasPrefix(line, ":") { + continue + } + field, value, found := strings.Cut(line, ":") + if !found { + field, value = line, "" + } else { + value = strings.TrimPrefix(value, " ") + } + switch field { + case "event": + event.Type = value + case "data": + dataBytes += len(value) + if len(data) > 0 { + dataBytes++ + } + if dataBytes > maxEventData { + return errors.New("event data exceeds the 4 MiB limit") + } + data = append(data, value) + case "id": + if !strings.ContainsRune(value, '\x00') { + event.ID = value + } + case "retry": + milliseconds, parseErr := strconv.ParseInt(value, 10, 64) + if parseErr == nil && milliseconds >= 0 && + milliseconds <= int64(^uint64(0)>>1)/int64(time.Millisecond) { + retryDelay = time.Duration(milliseconds) * time.Millisecond + } + } + } + if err := scanner.Err(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("read event stream: %w", err) + } + if err := ctx.Err(); err != nil { + return err + } + // The SSE parsing algorithm discards a partially received event at EOF. A + // blank line is required to dispatch it. + return nil +} diff --git a/internal/eventstream/client_test.go b/internal/eventstream/client_test.go new file mode 100644 index 0000000..bf26643 --- /dev/null +++ b/internal/eventstream/client_test.go @@ -0,0 +1,162 @@ +package eventstream + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +func TestWatchDecodesEventsAndAuthenticates(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + if request.URL.Path != endpoint || + request.Header.Get("Accept") != "text/event-stream" || + request.Header.Get("Authorization") != "Bearer access-token" { + t.Fatalf("request: %s headers=%v", request.URL.Path, request.Header) + } + writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + _, _ = io.WriteString(writer, ": keepalive\r\n") + _, _ = io.WriteString(writer, "event: file-touched\r\n") + _, _ = io.WriteString(writer, "id: event-1\r\n") + _, _ = io.WriteString(writer, "retry: 1500\r\n") + _, _ = io.WriteString(writer, "data: {\"item\":\r\n") + _, _ = io.WriteString(writer, "data: \"report.txt\"}\r\n\r\n") + })) + defer server.Close() + + client := NewClient(httpapi.Config{ + Server: server.URL, AuthType: "oidc", AccessToken: "access-token", + }, server.Client()) + var events []Event + connected := false + err := client.Watch(context.Background(), func() { + connected = true + }, func(event Event) error { + events = append(events, event) + return nil + }) + if err != nil { + t.Fatal(err) + } + if !connected || len(events) != 1 || events[0].Type != "file-touched" || + events[0].ID != "event-1" || events[0].Retry != 1500*time.Millisecond || + events[0].Data != "{\"item\":\n\"report.txt\"}" { + t.Fatalf("events: %#v", events) + } +} + +func TestWatchRejectsHTTPAndContentTypeFailures(t *testing.T) { + tests := []struct { + status int + contentType string + want string + }{ + {status: http.StatusUnauthorized, want: "401 Unauthorized"}, + {status: http.StatusOK, contentType: "application/json", want: "text/event-stream"}, + } + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + writer.Header().Set("Content-Type", test.contentType) + writer.WriteHeader(test.status) + })) + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + err := client.Watch( + context.Background(), nil, func(Event) error { return nil }, + ) + server.Close() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("status=%d contentType=%q error=%v", test.status, test.contentType, err) + } + } +} + +func TestWatchHonorsContextCancellation(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + writer.Header().Set("Content-Type", "text/event-stream") + writer.WriteHeader(http.StatusOK) + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } + close(started) + <-request.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + go func() { + done <- client.Watch(ctx, nil, func(Event) error { return nil }) + }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("error: %v", err) + } +} + +func TestDecodeDefaultsTypeAndPropagatesHandlerError(t *testing.T) { + response := &http.Response{Body: io.NopCloser(strings.NewReader("data: value\n\n"))} + want := errors.New("stop") + err := decode(context.Background(), response, func(event Event) error { + if event.Type != "message" || event.Data != "value" { + t.Fatalf("event: %#v", event) + } + return want + }) + if !errors.Is(err, want) { + t.Fatalf("error: %v", err) + } +} + +func TestDecodeDiscardsIncompleteEventAtEOF(t *testing.T) { + response := &http.Response{Body: io.NopCloser(strings.NewReader("data: incomplete\n"))} + called := false + if err := decode(context.Background(), response, func(Event) error { + called = true + return nil + }); err != nil { + t.Fatal(err) + } + if called { + t.Fatal("incomplete event was dispatched") + } +} + +func TestDecodePreservesReconnectDelayAcrossComments(t *testing.T) { + input := "retry: 750\n\n: keepalive\n\nevent: ready\ndata: {}\n\n" + response := &http.Response{Body: io.NopCloser(strings.NewReader(input))} + var got Event + if err := decode(context.Background(), response, func(event Event) error { + got = event + return nil + }); err != nil { + t.Fatal(err) + } + if got.Type != "ready" || got.Retry != 750*time.Millisecond { + t.Fatalf("event: %#v", got) + } +} + +func TestDecodeRejectsOversizedEvent(t *testing.T) { + line := "data: " + strings.Repeat("x", maxLineBytes-16) + "\n" + data := strings.Repeat(line, 5) + "\n" + response := &http.Response{Body: io.NopCloser(strings.NewReader(data))} + err := decode(context.Background(), response, func(Event) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("error: %v", err) + } +} diff --git a/internal/eventstream/types.go b/internal/eventstream/types.go new file mode 100644 index 0000000..b5babec --- /dev/null +++ b/internal/eventstream/types.go @@ -0,0 +1,46 @@ +package eventstream + +// TypeInfo describes an event name currently known by the CLI. +type TypeInfo struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// KnownTypes returns the event names currently emitted by oCIS services and +// consumed by the oCIS Web client. A server can add further event names without +// requiring a CLI update; event watch does not reject unknown names. +func KnownTypes() []TypeInfo { + return []TypeInfo{ + {Name: "userlog-notification", Description: "Unread notification received"}, + {Name: "postprocessing-finished", Description: "Upload processing finished"}, + {Name: "file-locked", Description: "File locked"}, + {Name: "file-unlocked", Description: "File unlocked"}, + {Name: "file-touched", Description: "File changed"}, + {Name: "item-renamed", Description: "Resource renamed"}, + {Name: "item-trashed", Description: "Resource moved to trash"}, + {Name: "item-restored", Description: "Resource restored from trash"}, + {Name: "item-moved", Description: "Resource moved"}, + {Name: "folder-created", Description: "Folder created"}, + {Name: "space-member-added", Description: "Space member added"}, + {Name: "space-member-removed", Description: "Space member removed"}, + {Name: "space-share-updated", Description: "Space membership updated"}, + {Name: "share-created", Description: "Share created"}, + {Name: "share-removed", Description: "Share removed"}, + {Name: "share-updated", Description: "Share updated"}, + {Name: "link-created", Description: "Public link created"}, + {Name: "link-removed", Description: "Public link removed"}, + {Name: "link-updated", Description: "Public link updated"}, + {Name: "backchannel-logout", Description: "Login session ended by server"}, + } +} + +// Description returns the friendly description of name, or name itself when +// the server emitted a newer event unknown to this CLI. +func Description(name string) string { + for _, eventType := range KnownTypes() { + if eventType.Name == name { + return eventType.Description + } + } + return name +} diff --git a/internal/sharing/client.go b/internal/sharing/client.go index 770bdd4..602c1e1 100644 --- a/internal/sharing/client.go +++ b/internal/sharing/client.go @@ -49,6 +49,9 @@ type ListRequest struct { // Capabilities reports server support relevant to direct sharing, Spaces, and // public links. type Capabilities struct { + Core struct { + SupportSSE bool `json:"supportSSE"` + } `json:"core"` Auth struct { MFA struct { Enabled bool `json:"enabled"` @@ -248,6 +251,9 @@ func (client *Client) Capabilities(ctx context.Context) (Capabilities, error) { } var raw struct { Capabilities struct { + Core struct { + SupportSSE json.RawMessage `json:"support-sse"` + } `json:"core"` Auth struct { MFA struct { Enabled bool `json:"enabled"` @@ -303,6 +309,7 @@ func (client *Client) Capabilities(ctx context.Context) (Capabilities, error) { return Capabilities{}, err } var result Capabilities + result.Core.SupportSSE = capabilityBool(raw.Capabilities.Core.SupportSSE) result.Auth.MFA.Enabled = raw.Capabilities.Auth.MFA.Enabled result.Auth.MFA.LevelNames = raw.Capabilities.Auth.MFA.LevelNames result.Auth.MFA.SessionDuration = diff --git a/internal/sharing/client_test.go b/internal/sharing/client_test.go index cc84eae..b371074 100644 --- a/internal/sharing/client_test.go +++ b/internal/sharing/client_test.go @@ -83,6 +83,7 @@ func TestCapabilities(t *testing.T) { t.Fatalf("path: %s", request.URL.Path) } writeOCS(writer, `{"capabilities":{ + "core":{"support-sse":true}, "dav":{"reports":["search-files"]}, "files":{"tus_support":{ "version":"1.0.0","resumable":"1.0.0", @@ -108,7 +109,8 @@ func TestCapabilities(t *testing.T) { if err != nil { t.Fatal(err) } - if !capabilities.Sharing.APIEnabled || + if !capabilities.Core.SupportSSE || + !capabilities.Sharing.APIEnabled || len(capabilities.DAV.Reports) != 1 || capabilities.DAV.Reports[0] != "search-files" || !capabilities.Sharing.GroupEnabled ||