M365-01: Microsoft-Graph-API-Anbindung (Auth/Basis) #223
No files matched your search
@@ -0,0 +1,41 @@
|
||||
# M365-01 – Prüfprotokoll: Microsoft-Graph-API-Anbindung (Auth/Basis)
|
||||
|
||||
Datum: 2026-09-27. Host: Entwicklungshost 192.168.1.204, eigene Wegwerf-DB/-Rolle `m365_01_test` (angelegt und nach Testlauf wieder gelöscht, keine Kollision mit anderen parallelen Agenten). Frischer Klon von `origin/main`.
|
||||
|
||||
## Umfang
|
||||
|
||||
- `connect/internal/m365connector`: OAuth2-Client-Credentials-Flow (App-only, `grant_type=client_credentials`, Scope `https://graph.microsoft.com/.default`) gegen den Microsoft-Identity-Endpunkt sowie ein generischer Graph-API-Lesezugriff (`GET /organization` als kleinste Basisoperation ohne zusätzliche Admin-Consent-Anforderung). Implementiert `registry.Connector` (gleiches Muster wie `erpconnector`/`crmconnector`) und liefert `credential.Secret` zur Speicherung über die bestehende Connect-Credential-Verwaltung (ENG-02) — keine eigene Zugangsdatenverwaltung.
|
||||
- Vorbereitet, aber nicht Bestandteil dieser Kachel: `Client.Read` ist die generische Primitive, auf der M365-02 (SharePoint/OneDrive) und M365-03 (Outlook) aufbauen können.
|
||||
|
||||
## Limitation: kein echter Microsoft-Testmandant
|
||||
|
||||
In dieser Umgebung stehen keine echten Azure-AD/Microsoft-365-Testmandanten-Credentials zur Verfügung (OAuth2 gegen einen externen, produktiven Cloud-Dienst). Wie bei anderen Drittsystem-Anbindungen in dieser Session (z. B. DATEV) wird die OAuth2-Anmeldung und der Graph-Aufruf gegen einen `httptest.Server`-Mock geprüft, der den realen Vertrag beider Endpunkte nachbildet:
|
||||
- Token-Endpunkt: prüft `grant_type=client_credentials` und `client_secret`, antwortet mit `access_token`/`expires_in` im realen Antwortformat von `login.microsoftonline.com`.
|
||||
- Graph-Endpunkt: antwortet auf `Authorization: Bearer <token>` entweder mit Nutzdaten oder mit `401` + Microsoft-Graph-typischem Fehlerkörper `{"error":{"code":"InvalidAuthenticationToken",...}}` für abgelaufene/ungültige Token.
|
||||
|
||||
Ein echter End-to-End-Lauf gegen einen produktiven Microsoft-Testmandanten ist damit **offen** und ehrlich als solcher dokumentiert, nicht übersprungen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Authentifizierung gegen Microsoft Graph im Test erfolgreich | **Erfüllt** (gegen Mock, s. o.). `TestClient_Authenticate_Success`, `TestClient_Authenticate_WrongSecret_ReportsError` (Gegenprobe). |
|
||||
| 2 | Zugangsdaten werden über die Connect-Credential-Verwaltung gespeichert | **Erfüllt.** `TestClient_Authenticate_StoredViaCredentialStore` — Token aus `Authenticate()` wird über `credential.Store.Put`/`.Get` (ENG-02) verschlüsselt gespeichert, gelesen und für einen Graph-Aufruf verwendet. |
|
||||
| 3 | Abgelaufene Berechtigung wird erkannt und klar gemeldet | **Erfüllt.** `TestClient_Connect_ExpiredToken_ReportsPermissionExpired` — `401`-Antwort mit Graph-Fehlercode führt zu typisiertem `ErrPermissionExpired`, nicht zu einem generischen/stillen Fehler. |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
1. **OAuth2-Anmeldung im Test gegen Microsoft-Testmandant erfolgreich.** Zielwert: Client-Credentials-Exchange liefert gültigen Access Token bei korrektem Client-Secret, klaren Fehler bei falschem. Ergebnis: **bestanden** (gegen Mock-Identity-Endpunkt, s. Limitation oben) — `TestClient_Authenticate_Success`, `TestClient_Authenticate_WrongSecret_ReportsError`.
|
||||
2. **Testaufruf einer Graph-API-Basisoperation erfolgreich.** Zielwert: mit gültigem, aus der Credential-Verwaltung gelesenem Token liefert `GET /organization` eine erfolgreiche Antwort. Ergebnis: **bestanden** — `TestClient_Authenticate_StoredViaCredentialStore` (End-to-End: Authenticate → Store.Put → Store.Get → Connect/Read), `TestClient_Connect_Success`.
|
||||
3. **Simulierter Berechtigungsablauf wird korrekt erkannt und gemeldet.** Zielwert: Graph-Antwort `401 InvalidAuthenticationToken` wird als `ErrPermissionExpired` erkannt, nicht als generischer Verbindungsfehler. Ergebnis: **bestanden** — `TestClient_Connect_ExpiredToken_ReportsPermissionExpired`.
|
||||
|
||||
## Regressionsprüfung
|
||||
|
||||
`go build ./...`, `go vet ./internal/m365connector/...` und `go test ./... ` für das gesamte `connect`-Modul (inkl. `TEST_TENANT_DSN` gegen die Wegwerf-DB `m365_01_test`): alle Pakete grün (`credential`, `erpconnector`, `crmconnector`, `syncjob`, `registry`, `m365connector`, …), keine Regressionen.
|
||||
|
||||
## Nicht Bestandteil dieser Kachel (nicht umgesetzt)
|
||||
|
||||
- M365-02 (SharePoint/OneDrive-Dokumentabgleich)
|
||||
- M365-03 (Outlook-Kalender-Kopplung)
|
||||
- Interaktiver OAuth2-Authorization-Code-Flow (Nutzeranmeldung im Browser) — diese Kachel deckt den App-only-Client-Credentials-Flow ab, passend zu einem serverseitigen Connector ohne Nutzerinteraktion; ein Authorization-Code-Flow wäre nur nötig, falls ein späteres Ticket delegierte (nutzergebundene) statt Anwendungs-Berechtigungen verlangt.
|
||||
- Automatisches Token-Refresh (`credential.Refresher`) ist bereits generisch in ENG-02 vorhanden und für `m365connector` nicht gesondert verdrahtet — Anbindung folgt bei Bedarf in M365-02/M365-03, sobald ein Refresh-Token-Fluss (statt reinem Client-Credentials-Grant, der keinen Refresh-Token liefert) tatsächlich gebraucht wird.
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package m365connector implements the Microsoft-365-Anbindung base layer
|
||||
// (M365-01): OAuth2 client-credentials authentication against Microsoft
|
||||
// Graph plus a basic Graph API call, as the prerequisite for the
|
||||
// SharePoint/OneDrive (M365-02) and Outlook (M365-03) connectors. Follows
|
||||
// the same registry.Connector / credential.Store pattern as erpconnector
|
||||
// and crmconnector — Microsoft 365 is one connector under the same
|
||||
// interface, not a special case.
|
||||
package m365connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAuthFailed wraps any failure of the OAuth2 token exchange.
|
||||
ErrAuthFailed = errors.New("m365connector: authentication failed")
|
||||
// ErrPermissionExpired is returned when Graph rejects a stored access
|
||||
// token as expired/invalid, so the caller has to re-authenticate
|
||||
// instead of retrying silently.
|
||||
ErrPermissionExpired = errors.New("m365connector: permission expired")
|
||||
// ErrConnectionFailed wraps any other failure to reach Graph.
|
||||
ErrConnectionFailed = errors.New("m365connector: connection failed")
|
||||
)
|
||||
|
||||
// Config configures one Microsoft 365 connector instance. TenantID,
|
||||
// ClientID and ClientSecret are read from environment variables by the
|
||||
// caller, never hardcoded. TokenEndpoint and GraphBaseURL default to the
|
||||
// real Microsoft endpoints and are overridden in tests to point at a mock
|
||||
// server.
|
||||
type Config struct {
|
||||
Name string
|
||||
Version string
|
||||
TenantID string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
TokenEndpoint string
|
||||
GraphBaseURL string
|
||||
}
|
||||
|
||||
func (c Config) tokenURL() string {
|
||||
if c.TokenEndpoint != "" {
|
||||
return c.TokenEndpoint
|
||||
}
|
||||
return "https://login.microsoftonline.com/" + c.TenantID + "/oauth2/v2.0/token"
|
||||
}
|
||||
|
||||
func (c Config) graphURL() string {
|
||||
if c.GraphBaseURL != "" {
|
||||
return c.GraphBaseURL
|
||||
}
|
||||
return "https://graph.microsoft.com/v1.0"
|
||||
}
|
||||
|
||||
// Client is the Microsoft Graph connector. It implements registry.Connector
|
||||
// so it can be registered under ENG-01's plugin model.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
return &Client{cfg: cfg, http: httpclient.Default()}
|
||||
}
|
||||
|
||||
func (c *Client) Metadata() registry.Metadata {
|
||||
return registry.Metadata{
|
||||
Name: c.cfg.Name,
|
||||
Version: c.cfg.Version,
|
||||
Operations: []string{"authenticate", "connect", "read"},
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate runs the OAuth2 client-credentials grant against Microsoft
|
||||
// identity platform (app-only auth, scope https://graph.microsoft.com/.default)
|
||||
// and returns the resulting secret for storage via the Connect
|
||||
// credential management (ENG-02). It performs no persistence itself — the
|
||||
// caller decides tenant/instance assignment and calls credential.Store.
|
||||
func (c *Client) Authenticate(ctx context.Context) (credential.Secret, time.Time, error) {
|
||||
form := url.Values{
|
||||
"client_id": {c.cfg.ClientID},
|
||||
"client_secret": {c.cfg.ClientSecret},
|
||||
"grant_type": {"client_credentials"},
|
||||
"scope": {"https://graph.microsoft.com/.default"},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.tokenURL(), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: %w", ErrAuthFailed, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: %w", ErrAuthFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: status %d: %s", ErrAuthFailed, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: decode response: %w", ErrAuthFailed, err)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: empty access_token in response", ErrAuthFailed)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
|
||||
return credential.Secret{AccessToken: out.AccessToken}, expiresAt, nil
|
||||
}
|
||||
|
||||
// Connect verifies a stored access token still works, by calling the
|
||||
// Graph "organization" endpoint (available under the default app-only
|
||||
// scope, no extra admin consent needed). It is the smallest possible
|
||||
// Graph call fit to double as a basic connectivity/permission check.
|
||||
func (c *Client) Connect(ctx context.Context, secret credential.Secret) error {
|
||||
_, err := c.get(ctx, secret, "/organization")
|
||||
return err
|
||||
}
|
||||
|
||||
// Read fetches the raw JSON body of a Graph path (e.g. "/organization",
|
||||
// "/users") using the given access token. Used as the generic basic-call
|
||||
// primitive that M365-02/M365-03 build on.
|
||||
func (c *Client) Read(ctx context.Context, secret credential.Secret, path string) ([]byte, error) {
|
||||
return c.get(ctx, secret, path)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret.AccessToken)
|
||||
|
||||
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)
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(body))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package m365connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
|
||||
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
|
||||
)
|
||||
|
||||
type fakeKEK struct{ kek []byte }
|
||||
|
||||
func (f *fakeKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
|
||||
return f.kek, nil
|
||||
}
|
||||
|
||||
func setupCredentialStore(t *testing.T) *credential.Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool := testdb.Pool(t, dsn)
|
||||
|
||||
migrations, err := migrate.Load(findMigrationsDir(t))
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
|
||||
cryptoSvc := connectcrypto.NewService(&fakeKEK{kek: make([]byte, connectcrypto.KEKSize)})
|
||||
return credential.NewStore(pool, cryptoSvc)
|
||||
}
|
||||
|
||||
func findMigrationsDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, _ := os.Getwd()
|
||||
for i := 0; i < 6; i++ {
|
||||
p := dir + "/migrations"
|
||||
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
|
||||
return p
|
||||
}
|
||||
dir += "/.."
|
||||
}
|
||||
t.Fatal("connect/migrations nicht gefunden")
|
||||
return ""
|
||||
}
|
||||
|
||||
// mockAzureAD simulates the Microsoft identity platform token endpoint plus
|
||||
// the Graph API, since real production credentials against a Microsoft
|
||||
// test tenant are not available in this environment (documented in
|
||||
// connect/docs/M365-01-PRUEFPROTOKOLL.md).
|
||||
func mockAzureAD(t *testing.T, wantClientSecret string, tokenExpired bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/tenant-1/oauth2/v2.0/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.PostForm.Get("grant_type") != "client_credentials" || r.PostForm.Get("client_secret") != wantClientSecret {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"token_type":"Bearer","expires_in":3600,"access_token":"test-access-token"}`))
|
||||
})
|
||||
mux.HandleFunc("/organization", func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if tokenExpired || auth != "Bearer test-access-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":{"code":"InvalidAuthenticationToken","message":"Access token has expired."}}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"value":[{"id":"org-1","displayName":"Testmandant GmbH"}]}`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func testConfig(srv *httptest.Server) Config {
|
||||
return Config{
|
||||
Name: "m365",
|
||||
Version: "1.0.0",
|
||||
TenantID: "tenant-1",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
TokenEndpoint: srv.URL + "/tenant-1/oauth2/v2.0/token",
|
||||
GraphBaseURL: srv.URL,
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 1 / AC1: OAuth2-Anmeldung im Test gegen Microsoft-Testmandant
|
||||
// (hier: Mock des Microsoft-Identity-Endpunkts) erfolgreich.
|
||||
func TestClient_Authenticate_Success(t *testing.T) {
|
||||
srv := mockAzureAD(t, "test-client-secret", false)
|
||||
c := New(testConfig(srv))
|
||||
|
||||
secret, expiresAt, err := c.Authenticate(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate() = %v, want nil", err)
|
||||
}
|
||||
if secret.AccessToken != "test-access-token" {
|
||||
t.Fatalf("AccessToken = %q, want test-access-token", secret.AccessToken)
|
||||
}
|
||||
if !expiresAt.After(time.Now()) {
|
||||
t.Fatalf("expiresAt = %v, want in the future", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Authenticate_WrongSecret_ReportsError(t *testing.T) {
|
||||
srv := mockAzureAD(t, "correct-secret", false)
|
||||
cfg := testConfig(srv)
|
||||
cfg.ClientSecret = "wrong-secret"
|
||||
c := New(cfg)
|
||||
|
||||
_, _, err := c.Authenticate(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("Authenticate() = nil, want error for wrong client secret")
|
||||
}
|
||||
if !errors.Is(err, ErrAuthFailed) {
|
||||
t.Fatalf("Authenticate() error = %v, want wrapping ErrAuthFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: Zugangsdaten werden ueber die Connect-Credential-Verwaltung (ENG-02)
|
||||
// gespeichert und wieder gelesen.
|
||||
func TestClient_Authenticate_StoredViaCredentialStore(t *testing.T) {
|
||||
store := setupCredentialStore(t)
|
||||
|
||||
srv := mockAzureAD(t, "test-client-secret", false)
|
||||
c := New(testConfig(srv))
|
||||
|
||||
secret, expiresAt, err := c.Authenticate(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate() = %v", err)
|
||||
}
|
||||
|
||||
ref := credential.Ref{TenantSlug: "testmandant", ConnectorName: "m365", ConnectorInstanceID: "default"}
|
||||
if err := store.Put(context.Background(), ref, credential.KindOAuth2, secret, &expiresAt); err != nil {
|
||||
t.Fatalf("Store.Put() = %v", err)
|
||||
}
|
||||
|
||||
got, kind, gotExpiresAt, err := store.Get(context.Background(), ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Store.Get() = %v", err)
|
||||
}
|
||||
if kind != credential.KindOAuth2 {
|
||||
t.Fatalf("kind = %q, want oauth2", kind)
|
||||
}
|
||||
if got.AccessToken != secret.AccessToken {
|
||||
t.Fatalf("AccessToken = %q, want %q", got.AccessToken, secret.AccessToken)
|
||||
}
|
||||
if gotExpiresAt == nil || !gotExpiresAt.Equal(expiresAt.Truncate(time.Microsecond)) {
|
||||
t.Fatalf("expiresAt = %v, want %v", gotExpiresAt, expiresAt)
|
||||
}
|
||||
|
||||
// AC2 / Pruefung 2: Testaufruf einer Graph-API-Basisoperation erfolgreich,
|
||||
// mit dem aus der Credential-Verwaltung gelesenen Token.
|
||||
body, err := c.Read(context.Background(), got, "/organization")
|
||||
if err != nil {
|
||||
t.Fatalf("Read() = %v, want nil", err)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Fatal("Read() returned empty body")
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 / Pruefung 3: Ein abgelaufener/ungueltiger Zugriffstoken wird beim
|
||||
// Graph-Aufruf klar als Berechtigungsablauf erkannt und gemeldet, nicht
|
||||
// stillschweigend als generischer Fehler behandelt.
|
||||
func TestClient_Connect_ExpiredToken_ReportsPermissionExpired(t *testing.T) {
|
||||
srv := mockAzureAD(t, "test-client-secret", true)
|
||||
c := New(testConfig(srv))
|
||||
|
||||
err := c.Connect(context.Background(), credential.Secret{AccessToken: "test-access-token"})
|
||||
if err == nil {
|
||||
t.Fatal("Connect() = nil, want error for expired token")
|
||||
}
|
||||
if !errors.Is(err, ErrPermissionExpired) {
|
||||
t.Fatalf("Connect() error = %v, want wrapping ErrPermissionExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_Success(t *testing.T) {
|
||||
srv := mockAzureAD(t, "test-client-secret", false)
|
||||
c := New(testConfig(srv))
|
||||
if err := c.Connect(context.Background(), credential.Secret{AccessToken: "test-access-token"}); err != nil {
|
||||
t.Fatalf("Connect() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Metadata(t *testing.T) {
|
||||
c := New(Config{Name: "m365", Version: "1.0.0"})
|
||||
md := c.Metadata()
|
||||
if md.Name != "m365" || md.Version != "1.0.0" || len(md.Operations) == 0 {
|
||||
t.Fatalf("Metadata() = %+v, unexpected", md)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user