feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
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.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DROP TABLE platform_connection;
|
||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||
-- ON CONFLICT in UpsertPlatformConnection).
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
108
internal/store/platformconnection.go
Normal file
108
internal/store/platformconnection.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PlatformConnection ist die per OAuth hergestellte Verbindung eines
|
||||
// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
|
||||
// internal/socialconnect). Anders als asset/finding/extraction NICHT
|
||||
// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte,
|
||||
// eine getrennte Verbindung wird wirklich gelöscht.
|
||||
type PlatformConnection struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Platform string
|
||||
PlatformUserID string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt *time.Time
|
||||
ConnectedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die
|
||||
// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem
|
||||
// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde).
|
||||
func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) {
|
||||
var c PlatformConnection
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (account_id, platform) DO UPDATE SET
|
||||
platform_user_id = EXCLUDED.platform_user_id,
|
||||
access_token = EXCLUDED.access_token,
|
||||
refresh_token = EXCLUDED.refresh_token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
connected_at = now()
|
||||
RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||
`, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan(
|
||||
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen
|
||||
// eines Accounts.
|
||||
func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||
FROM platform_connection WHERE account_id = $1 ORDER BY platform
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list platform connections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PlatformConnection
|
||||
for rows.Next() {
|
||||
var c PlatformConnection
|
||||
if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan platform connection: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list platform connections: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPlatformConnection liest die Verbindung eines Accounts zu einer
|
||||
// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung
|
||||
// besteht — der Normalfall, solange der Kunde nichts verbunden hat.
|
||||
func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) {
|
||||
var c PlatformConnection
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||
FROM platform_connection WHERE account_id = $1 AND platform = $2
|
||||
`, accountID, platform).Scan(
|
||||
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PlatformConnection{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeletePlatformConnection trennt eine Plattform-Verbindung.
|
||||
func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete platform connection: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
114
internal/store/platformconnection_test.go
Normal file
114
internal/store/platformconnection_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestPlatformConnectionUpsertGetDelete(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
expires := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
c, err := s.UpsertPlatformConnection(ctx, accID, "instagram", "ig-user-1", "access-1", "", &expires)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection: %v", err)
|
||||
}
|
||||
if c.Platform != "instagram" || c.PlatformUserID != "ig-user-1" {
|
||||
t.Fatalf("UpsertPlatformConnection = %+v, unerwartete Werte", c)
|
||||
}
|
||||
|
||||
got, err := s.GetPlatformConnection(ctx, accID, "instagram")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlatformConnection: %v", err)
|
||||
}
|
||||
if got.AccessToken != "access-1" {
|
||||
t.Fatalf("AccessToken = %q, want access-1", got.AccessToken)
|
||||
}
|
||||
|
||||
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected exactly 1 connection, got %d", len(list))
|
||||
}
|
||||
|
||||
if err := s.DeletePlatformConnection(ctx, accID, "instagram"); err != nil {
|
||||
t.Fatalf("DeletePlatformConnection: %v", err)
|
||||
}
|
||||
if _, err := s.GetPlatformConnection(ctx, accID, "instagram"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformConnectionUpsertReplacesExisting(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
first, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-alt", "refresh-alt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection (1): %v", err)
|
||||
}
|
||||
second, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-neu", "refresh-neu", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection (2): %v", err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("expected the same row to be updated (same account+platform), got a new ID")
|
||||
}
|
||||
if second.AccessToken != "access-neu" || second.RefreshToken != "refresh-neu" {
|
||||
t.Fatalf("UpsertPlatformConnection (2) = %+v, tokens wurden nicht ersetzt", second)
|
||||
}
|
||||
|
||||
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected exactly 1 connection after upsert-replace, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlatformConnectionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
_, err := s.GetPlatformConnection(ctx, accID, "instagram")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePlatformConnectionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
err := s.DeletePlatformConnection(ctx, accID, "tiktok")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformConnectionIsolatedPerAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.UpsertPlatformConnection(ctx, accA, "instagram", "ig-a", "token-a", "", nil); err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection (A): %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetPlatformConnection(ctx, accB, "instagram"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("Mandant B sollte keine Verbindung von Mandant A sehen, err = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user