Files
dingtalk-workspace-cli/internal/auth/app_config_path_test.go
T
c6edc84e40 fix(auth): partition app.json filename by edition to isolate credentials (#300)
* fix(auth): partition app.json filename by edition to isolate credentials

Two dws binaries sharing the same config directory (typically ~/.dws or
DWS_CONFIG_DIR) previously read and wrote a single app.json. Editions
that pin AuthClientID via hooks still go through the open-core
post-login persistence path, which records a bare ClientID without a
paired ClientSecret. The sibling edition reading the same path would
then adopt that foreign clientID via ResolveAppCredentials.

Mirror the strategy already used by the cache loader
(pkg/config.EditionPartition): GetAppConfigPath returns a filename
suffixed with the active edition name. Open-source keeps "app.json" for
backwards compatibility; sibling editions land on "app-<edition>.json".
LoadAppConfig / SaveAppConfig / HasAppConfig / DeleteAppConfig all
route through GetAppConfigPath, so this single change physically
isolates credential files end-to-end without any read-time heuristics.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): address app config partition review

* fix(auth): clean legacy sibling app config

* test(auth): cover legacy app config cleanup guards

* fix(auth): close app config review follow-ups

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 17:43:20 +08:00

257 lines
8.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Verifies that two dws binaries from different editions sharing the same
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
// app.json files. Without partitioning, a sibling edition's post-login
// persistence path could leak its pinned ClientID into the open-source
// build by reading the shared file.
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
for _, name := range []string{"", "open"} {
edition.Override(&edition.Hooks{Name: name})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", "app.json")
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
}
}
}
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
editionName string
wantFile string
}{
{"wukong", "app-wukong.json"},
{"dev", "app-dev.json"},
{"embedded", "app-embedded.json"},
}
for _, tc := range cases {
edition.Override(&edition.Hooks{Name: tc.editionName})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", tc.wantFile)
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
}
}
}
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
// End-to-end invariant: when the same configDir is observed from two
// different editions, the resulting app.json paths must NOT collide.
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
const cfg = "/tmp/shared-cfg"
edition.Override(&edition.Hooks{Name: "open"})
openPath := GetAppConfigPath(cfg)
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(cfg)
if openPath == wukongPath {
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
}
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
}
}
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(configDir)
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
t.Fatalf("writing sibling app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "open"})
got, err := LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig(open) error = %v", err)
}
if got != nil {
t.Fatalf("open edition read sibling app config: %#v", got)
}
}
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
t.Fatalf("sibling app config not written: %v", err)
}
}
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("different legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing malformed legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("malformed legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
siblingFiles := map[string][]byte{
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
}
for name, data := range siblingFiles {
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
t.Fatalf("writing sibling app config %s: %v", name, err)
}
}
edition.Override(&edition.Hooks{Name: "open"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
t.Fatalf("SaveAppConfig(open) error = %v", err)
}
for name, want := range siblingFiles {
got, err := os.ReadFile(filepath.Join(configDir, name))
if err != nil {
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
}
if string(got) != string(want) {
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
}
}
}
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}