mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
* fix(backup): harden tenant restore preview and lookup handling * test(pg): fix hook test migration path * test(ci): stabilize race coverage tests * fix(hooks): scope GetByID and allow loopback tests * fix(backup): harden tenant restore replace/new contracts + race-safe SSRF flag - Replace mode no longer deletes the tenants row (FK safe vs excluded diagnostic tables: traces, activity_logs, usage_snapshots, spans, embedding_cache, pairing_requests, paired_devices, channel_pending_messages, cron_run_logs). Metadata is preserved in place. - shouldRestoreTable now excludes tenants for both new and replace modes. - CLI: add validateTenantRestoreFlags guardrail. mode=new requires --new-tenant-slug and rejects --tenant/--tenant-id; upsert/replace warn on stray --new-tenant-slug; invalid --mode values rejected. TAB in help text fixed; flag descriptions clarified. - HTTP: resolveRestoreTarget rejects tenant_id for mode=new regardless of tenant_slug (matches CLI contract). New i18n key MsgRestoreNewModeRejectsTenantID (en/vi/zh). - security/ssrf: allowLoopbackForTest switched to atomic.Bool so concurrent reads from outbound dialers are race-safe. - Polish: vi backup.json key order matches en/zh; TenantRestoreOptions.Mode doc comment documents upsert/replace/new semantics including clone behavior for new. - Tests: unit coverage for validator (12 cases), HTTP guardrails (3 cases), shouldRestoreTable replace branch. Integration test tests/integration/tenant_restore_replace_test.go regression-guards the FK fix using activity_logs seed + DeleteTenantDataForTest helper. --------- Co-authored-by: Viet Tran <viettranx@gmail.com>
178 lines
6.1 KiB
Go
178 lines
6.1 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type fakeTenantStore struct {
|
|
tenant *store.TenantData
|
|
err error
|
|
}
|
|
|
|
func (f fakeTenantStore) CreateTenant(context.Context, *store.TenantData) error { return nil }
|
|
func (f fakeTenantStore) GetTenant(context.Context, uuid.UUID) (*store.TenantData, error) {
|
|
return f.tenant, f.err
|
|
}
|
|
func (f fakeTenantStore) GetTenantBySlug(context.Context, string) (*store.TenantData, error) {
|
|
return f.tenant, f.err
|
|
}
|
|
func (f fakeTenantStore) ListTenants(context.Context) ([]store.TenantData, error) { return nil, nil }
|
|
func (f fakeTenantStore) UpdateTenant(context.Context, uuid.UUID, map[string]any) error { return nil }
|
|
func (f fakeTenantStore) AddUser(context.Context, uuid.UUID, string, string) error { return nil }
|
|
func (f fakeTenantStore) RemoveUser(context.Context, uuid.UUID, string) error { return nil }
|
|
func (f fakeTenantStore) GetUserRole(context.Context, uuid.UUID, string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f fakeTenantStore) ListUsers(context.Context, uuid.UUID) ([]store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
func (f fakeTenantStore) ListUserTenants(context.Context, string) ([]store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
func (f fakeTenantStore) GetTenantsByIDs(context.Context, []uuid.UUID) ([]store.TenantData, error) {
|
|
return nil, nil
|
|
}
|
|
func (f fakeTenantStore) ResolveUserTenant(context.Context, string) (uuid.UUID, error) {
|
|
return uuid.Nil, nil
|
|
}
|
|
func (f fakeTenantStore) GetTenantUser(context.Context, uuid.UUID) (*store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
func (f fakeTenantStore) CreateTenantUserReturning(context.Context, uuid.UUID, string, string, string) (*store.TenantUserData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestResolveTenantLooksUpSlugForTenantID(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
handler := &TenantBackupHandler{
|
|
tenants: fakeTenantStore{tenant: &store.TenantData{ID: tenantID, Slug: "acme"}},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/tenant/backup?tenant_id="+tenantID.String(), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
id, slug, ok := handler.resolveTenant(rec, req)
|
|
if !ok {
|
|
t.Fatal("resolveTenant() returned ok=false")
|
|
}
|
|
if id != tenantID {
|
|
t.Fatalf("resolveTenant() id = %s, want %s", id, tenantID)
|
|
}
|
|
if slug != "acme" {
|
|
t.Fatalf("resolveTenant() slug = %q, want %q", slug, "acme")
|
|
}
|
|
}
|
|
|
|
func TestResolveTenantReturnsNotFoundForMissingTenant(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
handler := &TenantBackupHandler{tenants: fakeTenantStore{err: sql.ErrNoRows}}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/tenant/backup?tenant_id="+tenantID.String(), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
_, _, ok := handler.resolveTenant(rec, req)
|
|
if ok {
|
|
t.Fatal("resolveTenant() returned ok=true, want false")
|
|
}
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("resolveTenant() status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestResolveTenantReturnsInternalForLookupError(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
handler := &TenantBackupHandler{tenants: fakeTenantStore{err: errors.New("db down")}}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/tenant/backup?tenant_id="+tenantID.String(), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
_, _, ok := handler.resolveTenant(rec, req)
|
|
if ok {
|
|
t.Fatal("resolveTenant() returned ok=true, want false")
|
|
}
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("resolveTenant() status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTargetNewModeUsesSlug(t *testing.T) {
|
|
handler := &TenantBackupHandler{tenants: fakeTenantStore{}}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/tenant/restore?mode=new&tenant_slug=fresh-slug", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
id, slug, ok := handler.resolveRestoreTarget(rec, req, "new")
|
|
if !ok {
|
|
t.Fatal("resolveRestoreTarget() returned ok=false")
|
|
}
|
|
if id != uuid.Nil {
|
|
t.Fatalf("resolveRestoreTarget() id = %s, want uuid.Nil", id)
|
|
}
|
|
if slug != "fresh-slug" {
|
|
t.Fatalf("resolveRestoreTarget() slug = %q, want %q", slug, "fresh-slug")
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTargetNewModeRejectsTenantID(t *testing.T) {
|
|
handler := &TenantBackupHandler{tenants: fakeTenantStore{}}
|
|
req := httptest.NewRequest(http.MethodPost,
|
|
"/v1/tenant/restore?mode=new&tenant_id="+uuid.New().String(), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
_, _, ok := handler.resolveRestoreTarget(rec, req, "new")
|
|
if ok {
|
|
t.Fatal("resolveRestoreTarget() returned ok=true, want false")
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "tenant_slug") {
|
|
t.Fatalf("error body should mention tenant_slug; got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTargetNewModeRequiresSlug(t *testing.T) {
|
|
handler := &TenantBackupHandler{tenants: fakeTenantStore{}}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/tenant/restore?mode=new", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
_, _, ok := handler.resolveRestoreTarget(rec, req, "new")
|
|
if ok {
|
|
t.Fatal("resolveRestoreTarget() returned ok=true, want false")
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// TestResolveRestoreTargetNewModeRejectsTenantIDWithSlug verifies that
|
|
// mode=new rejects tenant_id even when tenant_slug is also provided — matches
|
|
// CLI contract, prevents ambiguous "which one wins" semantics.
|
|
func TestResolveRestoreTargetNewModeRejectsTenantIDWithSlug(t *testing.T) {
|
|
handler := &TenantBackupHandler{tenants: fakeTenantStore{}}
|
|
req := httptest.NewRequest(http.MethodPost,
|
|
"/v1/tenant/restore?mode=new&tenant_slug=fresh&tenant_id="+uuid.New().String(), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
_, _, ok := handler.resolveRestoreTarget(rec, req, "new")
|
|
if ok {
|
|
t.Fatal("resolveRestoreTarget() returned ok=true, want false")
|
|
}
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "tenant_slug") {
|
|
t.Fatalf("error body should reference the expected param; got %q", rec.Body.String())
|
|
}
|
|
}
|