Adds two helpers tailored for cron-driven or watcher-style clients: - AuthenticateContext(ctx) — cancellable variant of Authenticate(). Bypasses the non-context Query() path and POSTs /api/2.0/authentication.json directly, so a stalled auth call never outlives the caller's deadline. - InvalidateToken() — zeroes the cached *Token. Next request (or Authenticate*) forces a fresh auth. Intended for mid-sync 401 recovery when the server has revoked/rotated the session while local Expires still looks fresh. Plain Authenticate() is unchanged; it remains a convenience wrapper. Unit tests cover cache-hit, forced refresh, and context-cancellation paths. Refs eSlider/inventar-sync#3, ASR-0008. Made-with: Cursor
271 lines
8.4 KiB
Go
271 lines
8.4 KiB
Go
package onlyoffice
|
|
|
|
// Unit tests for the form/multipart helpers and a few CRM/Calendar/Subtask
|
|
// methods. These do not require a real OnlyOffice server — they spin up a
|
|
// net/http/httptest.Server that emulates the expected endpoints.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeServer wires a minimal OnlyOffice-like API: it always returns the same
|
|
// token on /api/2.0/authentication.json and dispatches remaining requests via
|
|
// the caller-supplied handler.
|
|
func fakeServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":{"token":"TEST_TOKEN","expires":"2099-01-01T00:00:00Z"}}`)
|
|
})
|
|
mux.HandleFunc("/", h)
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
// newTestClient builds a Client pointed at the test server with a primed token
|
|
// so helpers skip the auth round-trip when the test does not exercise it.
|
|
func newTestClient(srv *httptest.Server) *Client {
|
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
|
c.token = &Token{Value: "TEST_TOKEN", Expires: Time(time.Now().Add(time.Hour))}
|
|
return c
|
|
}
|
|
|
|
func TestAddSubtaskFormEncoded(t *testing.T) {
|
|
var gotPath, gotAuth, gotCT, gotBody string
|
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotCT = r.Header.Get("Content-Type")
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":{"id":99,"title":"child"}}`)
|
|
})
|
|
defer srv.Close()
|
|
c := newTestClient(srv)
|
|
|
|
out, err := c.AddSubtask(context.Background(), "42", "child")
|
|
if err != nil {
|
|
t.Fatalf("AddSubtask: %v", err)
|
|
}
|
|
if gotPath != "/api/2.0/project/task/42.json" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if gotAuth != "TEST_TOKEN" {
|
|
t.Errorf("auth header = %q", gotAuth)
|
|
}
|
|
if !strings.HasPrefix(gotCT, "application/x-www-form-urlencoded") {
|
|
t.Errorf("content-type = %q", gotCT)
|
|
}
|
|
if gotBody != "title=child" {
|
|
t.Errorf("body = %q", gotBody)
|
|
}
|
|
if fm, ok := out["title"].(string); !ok || fm != "child" {
|
|
t.Errorf("out.title = %v", out["title"])
|
|
}
|
|
}
|
|
|
|
func TestAddEventUsesDefaultCalendar(t *testing.T) {
|
|
var gotPath, gotBody string
|
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":[{"id":"e1","name":"hi"}]}`)
|
|
})
|
|
defer srv.Close()
|
|
c := newTestClient(srv)
|
|
c.SetDefaults(Defaults{CalendarID: "7"})
|
|
|
|
ev, err := c.AddEvent(context.Background(), "", "hi", "2025-01-01T10:00:00Z", "2025-01-01T11:00:00Z", "desc", false)
|
|
if err != nil {
|
|
t.Fatalf("AddEvent: %v", err)
|
|
}
|
|
if gotPath != "/api/2.0/calendar/7/event.json" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if !strings.Contains(gotBody, "name=hi") || !strings.Contains(gotBody, "isAllDayLong=false") {
|
|
t.Errorf("body = %q", gotBody)
|
|
}
|
|
if ev["id"] != "e1" {
|
|
t.Errorf("event id = %v", ev["id"])
|
|
}
|
|
}
|
|
|
|
func TestAddEventRequiresCalendar(t *testing.T) {
|
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("server must not be hit without calendar id")
|
|
})
|
|
defer srv.Close()
|
|
c := newTestClient(srv)
|
|
if _, err := c.AddEvent(context.Background(), "", "t", "s", "e", "", false); err == nil {
|
|
t.Fatal("expected error when no calendar id and no default")
|
|
}
|
|
}
|
|
|
|
func TestListContactsTotal(t *testing.T) {
|
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/2.0/crm/contact/filter.json" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
if r.URL.Query().Get("filterValue") != "acme" {
|
|
t.Errorf("filterValue = %q", r.URL.Query().Get("filterValue"))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":[{"id":1,"displayName":"ACME"}],"total":7}`)
|
|
})
|
|
defer srv.Close()
|
|
c := newTestClient(srv)
|
|
|
|
items, total, err := c.ListContacts(context.Background(), 50, 0, "acme")
|
|
if err != nil {
|
|
t.Fatalf("ListContacts: %v", err)
|
|
}
|
|
if total != 7 {
|
|
t.Errorf("total = %d", total)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("len(items) = %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestUploadOpportunityFileMultipart(t *testing.T) {
|
|
tmp := t.TempDir() + "/a.txt"
|
|
if err := writeFile(tmp, "hello"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var gotField, gotFilename, gotBody string
|
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
ct := r.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(ct, "multipart/form-data") {
|
|
t.Errorf("content-type = %q", ct)
|
|
}
|
|
_, params, err := mime.ParseMediaType(ct)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mr := multipart.NewReader(r.Body, params["boundary"])
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gotField = part.FormName()
|
|
gotFilename = part.FileName()
|
|
b, _ := io.ReadAll(part)
|
|
gotBody = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":{"id":1,"title":"a.txt"}}`)
|
|
})
|
|
defer srv.Close()
|
|
c := newTestClient(srv)
|
|
|
|
out, err := c.UploadOpportunityFile(context.Background(), "123", tmp)
|
|
if err != nil {
|
|
t.Fatalf("UploadOpportunityFile: %v", err)
|
|
}
|
|
if gotField != "file" || gotFilename != "a.txt" || gotBody != "hello" {
|
|
t.Errorf("multipart = field=%q filename=%q body=%q", gotField, gotFilename, gotBody)
|
|
}
|
|
if out["title"] != "a.txt" {
|
|
t.Errorf("out.title = %v", out["title"])
|
|
}
|
|
}
|
|
|
|
func writeFile(path, content string) error {
|
|
return os.WriteFile(path, []byte(content), 0o600)
|
|
}
|
|
|
|
func TestResponseFieldMissing(t *testing.T) {
|
|
_, err := responseField(json.RawMessage(`{"other":1}`), "response")
|
|
if err == nil {
|
|
t.Fatal("expected error on missing field")
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateContextUsesCacheWhenFresh(t *testing.T) {
|
|
var authHits int
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
|
authHits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":{"token":"FRESH_TOKEN","expires":"2099-01-01T00:00:00.0000000-00:00"}}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
|
|
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
|
t.Fatalf("first AuthenticateContext: %v", err)
|
|
}
|
|
if authHits != 1 {
|
|
t.Errorf("expected 1 auth hit after first call, got %d", authHits)
|
|
}
|
|
if c.token == nil || c.token.Value != "FRESH_TOKEN" {
|
|
t.Errorf("token not cached: %+v", c.token)
|
|
}
|
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
|
t.Fatalf("second AuthenticateContext: %v", err)
|
|
}
|
|
if authHits != 1 {
|
|
t.Errorf("cache bypassed: expected 1 auth hit, got %d", authHits)
|
|
}
|
|
}
|
|
|
|
func TestInvalidateTokenForcesReauth(t *testing.T) {
|
|
var authHits int
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
|
authHits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"response":{"token":"T","expires":"2099-01-01T00:00:00.0000000-00:00"}}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
|
|
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
|
t.Fatalf("auth: %v", err)
|
|
}
|
|
c.InvalidateToken()
|
|
if c.token != nil {
|
|
t.Fatalf("token still cached after Invalidate: %+v", c.token)
|
|
}
|
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
|
t.Fatalf("re-auth: %v", err)
|
|
}
|
|
if authHits != 2 {
|
|
t.Errorf("expected 2 auth hits after invalidate, got %d", authHits)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateContextRespectsCancellation(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-time.After(2 * time.Second):
|
|
_, _ = io.WriteString(w, `{"response":{"token":"T","expires":"2099-01-01T00:00:00.0000000-00:00"}}`)
|
|
}
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
if err := c.AuthenticateContext(ctx); err == nil {
|
|
t.Fatal("expected error on context timeout")
|
|
}
|
|
}
|