Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.
- internal/socialconnect: Connector-Interface + InstagramConnector/
TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
(State-Cookie gegen CSRF, Redirect zum Consent-Screen),
GET /oauth/{platform}/callback (State prüfen, Code tauschen,
Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
"nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.
Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
226 lines
8.0 KiB
Go
226 lines
8.0 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/socialconnect"
|
|
)
|
|
|
|
// fakeConnector ist ein socialconnect.Connector-Fake für Tests — kein
|
|
// echter HTTP-Aufruf gegen Instagram/TikTok nötig.
|
|
type fakeConnector struct {
|
|
platform string
|
|
token socialconnect.Token
|
|
err error
|
|
}
|
|
|
|
func (f fakeConnector) Platform() string { return f.platform }
|
|
func (f fakeConnector) AuthorizationURL(state string) string {
|
|
return "https://provider.example/authorize?state=" + state + "&platform=" + f.platform
|
|
}
|
|
func (f fakeConnector) Exchange(ctx context.Context, code string) (socialconnect.Token, error) {
|
|
if f.err != nil {
|
|
return socialconnect.Token{}, f.err
|
|
}
|
|
return f.token, nil
|
|
}
|
|
|
|
func TestConnectionsListShowsUnconfiguredPlatformsWithoutConnectButton(t *testing.T) {
|
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
resp := getWithCookie(t, s, cookie, "/verbindungen")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
body := resp.Body.String()
|
|
if strings.Contains(body, "/oauth/instagram/start") || strings.Contains(body, "/oauth/tiktok/start") {
|
|
t.Errorf("expected no connect links when no connector is configured, got: %s", body)
|
|
}
|
|
if !strings.Contains(body, "instagram") || !strings.Contains(body, "tiktok") {
|
|
t.Errorf("expected both platforms to be listed regardless of configuration, got: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestConnectionsListShowsConnectButtonWhenConfigured(t *testing.T) {
|
|
fs := newFakeStore()
|
|
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
|
|
|
resp := getWithCookie(t, s, cookie, "/verbindungen")
|
|
body := resp.Body.String()
|
|
if !strings.Contains(body, "/oauth/instagram/start") {
|
|
t.Errorf("expected an Instagram connect link, got: %s", body)
|
|
}
|
|
if strings.Contains(body, "/oauth/tiktok/start") {
|
|
t.Errorf("expected no TikTok connect link (not configured), got: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestOAuthStartRedirectsToProviderAndSetsStateCookie(t *testing.T) {
|
|
fs := newFakeStore()
|
|
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/start", nil)
|
|
req.AddCookie(cookie)
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303", w.Code)
|
|
}
|
|
loc := w.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, "https://provider.example/authorize?") {
|
|
t.Fatalf("Location = %q, want a redirect to the provider", loc)
|
|
}
|
|
var stateCookie *http.Cookie
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == "deklarix_oauth_state" {
|
|
stateCookie = c
|
|
}
|
|
}
|
|
if stateCookie == nil || stateCookie.Value == "" {
|
|
t.Fatal("expected a non-empty deklarix_oauth_state cookie to be set")
|
|
}
|
|
}
|
|
|
|
func TestOAuthStartRejectsUnconfiguredPlatform(t *testing.T) {
|
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
resp := getWithCookie(t, s, cookie, "/oauth/instagram/start")
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 for an unconfigured platform", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallbackStoresConnectionOnValidState(t *testing.T) {
|
|
fs := newFakeStore()
|
|
expires := time.Now().Add(60 * 24 * time.Hour)
|
|
connectors := map[string]socialconnect.Connector{
|
|
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{
|
|
AccessToken: "ig-token", PlatformUserID: "ig-user-1", ExpiresAt: expires,
|
|
}},
|
|
}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=gueltiger-state", nil)
|
|
req.AddCookie(cookie)
|
|
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "gueltiger-state"})
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303, body: %s", w.Code, w.Body.String())
|
|
}
|
|
if loc := w.Header().Get("Location"); loc != "/verbindungen" {
|
|
t.Fatalf("Location = %q, want /verbindungen", loc)
|
|
}
|
|
|
|
var accID string
|
|
for id := range fs.accounts {
|
|
accID = id
|
|
}
|
|
conn, err := fs.GetPlatformConnection(context.Background(), accID, "instagram")
|
|
if err != nil {
|
|
t.Fatalf("expected a stored connection, got err: %v", err)
|
|
}
|
|
if conn.AccessToken != "ig-token" || conn.PlatformUserID != "ig-user-1" {
|
|
t.Errorf("unexpected connection: %+v", conn)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallbackRejectsMismatchedState(t *testing.T) {
|
|
fs := newFakeStore()
|
|
connectors := map[string]socialconnect.Connector{
|
|
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{AccessToken: "x", PlatformUserID: "y"}},
|
|
}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=falscher-state", nil)
|
|
req.AddCookie(cookie)
|
|
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "anderer-state"})
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 for a state mismatch", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestOAuthCallbackHandlesUserDenial(t *testing.T) {
|
|
fs := newFakeStore()
|
|
connectors := map[string]socialconnect.Connector{
|
|
"instagram": fakeConnector{platform: "instagram"},
|
|
}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?error=access_denied", nil)
|
|
req.AddCookie(cookie)
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303 (redirect back, no error page)", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDisconnectRemovesConnection(t *testing.T) {
|
|
fs := newFakeStore()
|
|
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
|
var accID string
|
|
for id := range fs.accounts {
|
|
accID = id
|
|
}
|
|
if _, err := fs.UpsertPlatformConnection(context.Background(), accID, "instagram", "u1", "tok", "", nil); err != nil {
|
|
t.Fatalf("UpsertPlatformConnection: %v", err)
|
|
}
|
|
|
|
resp := postForm(t, s, cookie, "/verbindungen/instagram/trennen", url.Values{})
|
|
if resp.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if _, err := fs.GetPlatformConnection(context.Background(), accID, "instagram"); err == nil {
|
|
t.Fatal("expected the connection to be gone after disconnect")
|
|
}
|
|
}
|
|
|
|
func TestConnectionsIsolatedPerTenant(t *testing.T) {
|
|
fs := newFakeStore()
|
|
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
|
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
|
|
|
var accA string
|
|
for id, acc := range fs.accounts {
|
|
if acc.Name == "Mandant A" {
|
|
accA = id
|
|
}
|
|
}
|
|
if _, err := fs.UpsertPlatformConnection(context.Background(), accA, "instagram", "u1", "tok", "", nil); err != nil {
|
|
t.Fatalf("UpsertPlatformConnection: %v", err)
|
|
}
|
|
|
|
respA := getWithCookie(t, s, cookieA, "/verbindungen")
|
|
if !strings.Contains(respA.Body.String(), "verbunden seit") {
|
|
t.Errorf("expected Mandant A to see their own connection, got: %s", respA.Body.String())
|
|
}
|
|
respB := getWithCookie(t, s, cookieB, "/verbindungen")
|
|
if strings.Contains(respB.Body.String(), "verbunden seit") {
|
|
t.Errorf("expected Mandant B to see no connection, got: %s", respB.Body.String())
|
|
}
|
|
}
|