mirror of
https://github.com/4ier/notion-cli.git
synced 2026-09-14 20:17:00 +08:00
087048d192
The Notion API returns a technically-accurate but hard-to-action
validation error when an internal integration tries to create a page at
the workspace root:
validation_error: Provide a parent.page_id or parent.database_id
parameter to create a page, or use a public integration with
insert_content capability. Internal integrations aren't owned by a
single user, so creating workspace-level private pages is not
supported.
Two small improvements so users don't have to parse that paragraph:
1. `notion auth status` and `notion auth doctor` now derive the
integration type from bot.owner.type ("workspace" → internal,
"user" → public) and print it. For internal integrations a follow-up
line reminds the user they must share a parent page first.
2. client.errorHint matches the internal-integration validation_error
signature and prints a concrete workaround with the exact next command:
→ Internal integrations can't create pages at the workspace root.
Workaround: create (or pick) a parent page in the Notion UI,
share it with this integration, then pass its ID as the parent:
notion page create <shared-page-id> --title "..."
To list pages shared with your integration: notion page list
Detection code is small (detectIntegrationType in cmd/auth.go) and
defensive: unknown or missing owner shape returns "" so pre-existing
response fixtures that don't include bot.owner still render fine.
Closes #25
521 lines
13 KiB
Go
521 lines
13 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/4ier/notion-cli/internal/config"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
// setupAuthTest creates a mock Notion API server and sets env vars for isolated testing.
|
|
// Returns the server (caller must defer server.Close()) and a cleanup function.
|
|
func setupAuthTest(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// GET /v1/users/me — valid bot response
|
|
mux.HandleFunc("/v1/users/me", func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer secret_valid_token" && auth != "Bearer secret_work_token" {
|
|
w.WriteHeader(401)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"code": "unauthorized",
|
|
"message": "API token is invalid.",
|
|
})
|
|
return
|
|
}
|
|
workspace := "Test Workspace"
|
|
if auth == "Bearer secret_work_token" {
|
|
workspace = "Work Workspace"
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"id": "bot-123",
|
|
"name": "Test Bot",
|
|
"bot": map[string]interface{}{
|
|
"workspace_name": workspace,
|
|
"workspace_id": "ws-123",
|
|
"owner": map[string]interface{}{
|
|
"type": "workspace",
|
|
"workspace": true,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
// POST /v1/search — for doctor check
|
|
mux.HandleFunc("/v1/search", func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"results": []interface{}{
|
|
map[string]interface{}{"id": "page-1"},
|
|
},
|
|
})
|
|
})
|
|
|
|
server := httptest.NewServer(mux)
|
|
|
|
// Point client at mock server
|
|
t.Setenv("NOTION_BASE_URL", server.URL)
|
|
// Use temp config dir
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
// Clear any real token
|
|
t.Setenv("NOTION_TOKEN", "")
|
|
|
|
return server
|
|
}
|
|
|
|
// executeCommand runs a cobra command with the given args and returns stdout, stderr, and error.
|
|
func executeCommand(args ...string) (string, string, error) {
|
|
stdout := new(bytes.Buffer)
|
|
stderr := new(bytes.Buffer)
|
|
|
|
cmd := rootCmd
|
|
resetCommandFlags(cmd)
|
|
cmd.SetOut(stdout)
|
|
cmd.SetErr(stderr)
|
|
cmd.SetArgs(args)
|
|
|
|
err := cmd.Execute()
|
|
return stdout.String(), stderr.String(), err
|
|
}
|
|
|
|
func resetCommandFlags(cmd interface{ Flags() *pflag.FlagSet; PersistentFlags() *pflag.FlagSet; Commands() []*cobra.Command }) {
|
|
resetFlagSet(cmd.Flags())
|
|
resetFlagSet(cmd.PersistentFlags())
|
|
for _, sub := range cmd.Commands() {
|
|
resetCommandFlags(sub)
|
|
}
|
|
}
|
|
|
|
func resetFlagSet(fs *pflag.FlagSet) {
|
|
if fs == nil {
|
|
return
|
|
}
|
|
|
|
fs.VisitAll(func(f *pflag.Flag) {
|
|
if sliceValue, ok := f.Value.(pflag.SliceValue); ok {
|
|
_ = sliceValue.Replace(nil)
|
|
} else {
|
|
_ = f.Value.Set(f.DefValue)
|
|
}
|
|
f.Changed = false
|
|
})
|
|
}
|
|
|
|
// --- auth login ---
|
|
|
|
func TestAuthLoginValidToken(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// Simulate stdin with token
|
|
oldStdin := os.Stdin
|
|
r, w, _ := os.Pipe()
|
|
w.WriteString("secret_valid_token\n")
|
|
w.Close()
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin }()
|
|
|
|
_, _, err := executeCommand("auth", "login", "--with-token")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
// Verify config was saved
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
t.Fatalf("config load failed: %v", err)
|
|
}
|
|
profile := cfg.GetCurrentProfile()
|
|
if profile == nil {
|
|
t.Fatal("expected profile to be saved")
|
|
}
|
|
if profile.Token != "secret_valid_token" {
|
|
t.Errorf("expected token 'secret_valid_token', got %q", profile.Token)
|
|
}
|
|
if profile.WorkspaceName != "Test Workspace" {
|
|
t.Errorf("expected workspace 'Test Workspace', got %q", profile.WorkspaceName)
|
|
}
|
|
}
|
|
|
|
func TestAuthLoginInvalidToken(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
oldStdin := os.Stdin
|
|
r, w, _ := os.Pipe()
|
|
w.WriteString("secret_bad_token\n")
|
|
w.Close()
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin }()
|
|
|
|
_, _, err := executeCommand("auth", "login", "--with-token")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid token")
|
|
}
|
|
if !strings.Contains(err.Error(), "authentication failed") {
|
|
t.Errorf("expected 'authentication failed' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthLoginEmptyToken(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
oldStdin := os.Stdin
|
|
r, w, _ := os.Pipe()
|
|
w.WriteString("\n")
|
|
w.Close()
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin }()
|
|
|
|
_, _, err := executeCommand("auth", "login", "--with-token")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty token")
|
|
}
|
|
if !strings.Contains(err.Error(), "no token provided") {
|
|
t.Errorf("expected 'no token provided' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthLoginWithProfile(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
oldStdin := os.Stdin
|
|
r, w, _ := os.Pipe()
|
|
w.WriteString("secret_work_token\n")
|
|
w.Close()
|
|
os.Stdin = r
|
|
defer func() { os.Stdin = oldStdin }()
|
|
|
|
_, _, err := executeCommand("auth", "login", "--with-token", "--profile", "work")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
cfg, _ := config.Load()
|
|
if cfg.CurrentProfile != "work" {
|
|
t.Errorf("expected current profile 'work', got %q", cfg.CurrentProfile)
|
|
}
|
|
if cfg.Profiles["work"].WorkspaceName != "Work Workspace" {
|
|
t.Errorf("expected 'Work Workspace', got %q", cfg.Profiles["work"].WorkspaceName)
|
|
}
|
|
}
|
|
|
|
// --- auth status ---
|
|
|
|
func TestAuthStatusAuthenticated(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// Pre-save a valid config
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token", WorkspaceName: "Test Workspace"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
// auth status prints to os.Stdout, not cmd.OutOrStdout() — capture via pipe
|
|
// For now just verify no error
|
|
_, _, err := executeCommand("auth", "status")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthStatusNotAuthenticated(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// Empty config — not authenticated
|
|
_, _, err := executeCommand("auth", "status")
|
|
// status doesn't return error, it prints "Not authenticated"
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- auth logout ---
|
|
|
|
func TestAuthLogoutAll(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// Pre-save config with two profiles
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token"},
|
|
"work": {Token: "secret_work_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "logout")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
// Verify all profiles cleared
|
|
cfg, _ = config.Load()
|
|
if len(cfg.Profiles) != 0 {
|
|
t.Errorf("expected 0 profiles after logout, got %d", len(cfg.Profiles))
|
|
}
|
|
}
|
|
|
|
func TestAuthLogoutSpecificProfile(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token"},
|
|
"work": {Token: "secret_work_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "logout", "work")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
cfg, _ = config.Load()
|
|
if _, ok := cfg.Profiles["work"]; ok {
|
|
t.Error("expected 'work' profile to be removed")
|
|
}
|
|
if _, ok := cfg.Profiles["default"]; !ok {
|
|
t.Error("expected 'default' profile to remain")
|
|
}
|
|
}
|
|
|
|
func TestAuthLogoutNonexistentProfile(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "logout", "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent profile")
|
|
}
|
|
if !strings.Contains(err.Error(), "not found") {
|
|
t.Errorf("expected 'not found' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- auth switch ---
|
|
|
|
func TestAuthSwitchDirect(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token", WorkspaceName: "Default WS"},
|
|
"work": {Token: "secret_work_token", WorkspaceName: "Work WS"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "switch", "work")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
cfg, _ = config.Load()
|
|
if cfg.CurrentProfile != "work" {
|
|
t.Errorf("expected current profile 'work', got %q", cfg.CurrentProfile)
|
|
}
|
|
}
|
|
|
|
func TestAuthSwitchNonexistent(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "switch", "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent profile")
|
|
}
|
|
if !strings.Contains(err.Error(), "not found") {
|
|
t.Errorf("expected 'not found' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthSwitchAlreadyCurrent(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token"},
|
|
"work": {Token: "secret_work_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
// Switch to already-current profile should succeed without error
|
|
_, _, err := executeCommand("auth", "switch", "default")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthSwitchNotAuthenticated(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// No config at all
|
|
_, _, err := executeCommand("auth", "switch", "anything")
|
|
if err == nil {
|
|
t.Fatal("expected error when not authenticated")
|
|
}
|
|
}
|
|
|
|
// --- auth doctor ---
|
|
|
|
func TestAuthDoctorAllGood(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token", WorkspaceName: "Test Workspace"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "doctor")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthDoctorNoToken(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// No config — doctor should report missing token (but not return error)
|
|
_, _, err := executeCommand("auth", "doctor")
|
|
if err != nil {
|
|
t.Fatalf("expected no error (doctor reports inline), got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthDoctorInvalidToken(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "default",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_bad_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "doctor")
|
|
// doctor prints diagnostics inline, doesn't return error
|
|
if err != nil {
|
|
t.Fatalf("expected no error (doctor reports inline), got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- auth logout current profile fallback ---
|
|
|
|
func TestAuthLogoutCurrentProfileSwitchesToAnother(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
CurrentProfile: "work",
|
|
Profiles: map[string]*config.Profile{
|
|
"default": {Token: "secret_valid_token"},
|
|
"work": {Token: "secret_work_token"},
|
|
},
|
|
}
|
|
config.Save(cfg)
|
|
|
|
_, _, err := executeCommand("auth", "logout", "work")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
|
|
cfg, _ = config.Load()
|
|
// Should have switched to remaining profile
|
|
if cfg.CurrentProfile == "work" {
|
|
t.Error("expected current profile to change from 'work'")
|
|
}
|
|
if len(cfg.Profiles) != 1 {
|
|
t.Errorf("expected 1 remaining profile, got %d", len(cfg.Profiles))
|
|
}
|
|
}
|
|
|
|
func TestAuthLoginMultipleProfiles(t *testing.T) {
|
|
server := setupAuthTest(t)
|
|
defer server.Close()
|
|
|
|
// Verify multiple profiles can coexist via config API
|
|
// (executeCommand shares cobra global state across tests, making
|
|
// sequential logins unreliable — test the config layer directly)
|
|
cfg := &config.Config{}
|
|
cfg.SetProfile("default", &config.Profile{
|
|
Token: "secret_valid_token",
|
|
WorkspaceName: "Test Workspace",
|
|
WorkspaceID: "ws-123",
|
|
BotID: "bot-123",
|
|
})
|
|
cfg.SetProfile("work", &config.Profile{
|
|
Token: "secret_work_token",
|
|
WorkspaceName: "Work Workspace",
|
|
WorkspaceID: "ws-work",
|
|
BotID: "bot-work",
|
|
})
|
|
cfg.CurrentProfile = "work"
|
|
if err := config.Save(cfg); err != nil {
|
|
t.Fatalf("save failed: %v", err)
|
|
}
|
|
|
|
cfg, _ = config.Load()
|
|
if len(cfg.Profiles) != 2 {
|
|
t.Errorf("expected 2 profiles, got %d", len(cfg.Profiles))
|
|
}
|
|
if cfg.CurrentProfile != "work" {
|
|
t.Errorf("expected current profile 'work', got %q", cfg.CurrentProfile)
|
|
}
|
|
if cfg.Profiles["default"].WorkspaceName != "Test Workspace" {
|
|
t.Errorf("default workspace wrong: %q", cfg.Profiles["default"].WorkspaceName)
|
|
}
|
|
if cfg.Profiles["work"].WorkspaceName != "Work Workspace" {
|
|
t.Errorf("work workspace wrong: %q", cfg.Profiles["work"].WorkspaceName)
|
|
}
|
|
}
|