@ripla/godd-mcp 1.0.6 → 1.0.7-canary.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/godd.cjs +194 -185
- package/dist/godd.js +191 -49
- package/dist/index.js +1 -1
- package/notes/api/.env.example +1 -4
- package/notes/api/README.md +4 -2
- package/notes/api/app/config.py +8 -5
- package/notes/api/app/routers/tree.py +3 -3
- package/notes/api/app/services/github.py +14 -13
- package/notes/api/app/services/github_app_token.py +10 -15
- package/notes/api/app/services/github_pr.py +14 -8
- package/notes/api/tests/test_files.py +2 -2
- package/notes/api/tests/test_github_app_token.py +6 -5
- package/notes/api/tests/test_github_service.py +30 -4
- package/notes/api/tests/test_pr_chain.py +25 -2
- package/notes/api/tests/test_tree.py +1 -1
- package/notes/app/e2e/pages/login-page.ts +6 -0
- package/notes/app/e2e/password-toggle.spec.ts +198 -0
- package/notes/app/src/components/PasswordInput.test.tsx +122 -0
- package/notes/app/src/components/PasswordInput.tsx +67 -0
- package/notes/app/src/pages/LoginPage.test.tsx +31 -1
- package/notes/app/src/pages/LoginPage.tsx +14 -8
- package/notes/app/src/pages/UserManagementPage.test.tsx +51 -0
- package/notes/app/src/pages/UserManagementPage.tsx +2 -2
- package/package.json +1 -1
- package/templates/github-actions/aws/deploy-notes.yml.hbs +31 -0
- package/templates/notes-compose.yml +0 -1
- package/templates/notes-docs/007_guides/setup_godd.md +46 -10
- package/templates/notes-docs/007_guides/setup_godd_notes.md +218 -16
- package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +409 -0
- package/templates/notes-docs/007_guides/setup_infra.template.md +75 -22
- package/templates/terraform/aws/ecs.tf.hbs +9 -1
- package/templates/terraform/aws/provider.tf.hbs +1 -1
- package/templates/terraform/aws/secrets.tf.hbs +11 -3
package/notes/api/.env.example
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# GitHub
|
|
2
|
-
# GITHUB_TOKEN=
|
|
3
|
-
|
|
4
|
-
# GitHub App credentials (recommended over PAT for bot authorship of PRs/Issues)
|
|
1
|
+
# GitHub App credentials (required for live GitHub API; PAT / GITHUB_TOKEN is forbidden)
|
|
5
2
|
# GITHUB_APP_ID=
|
|
6
3
|
# GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----
|
|
7
4
|
# GITHUB_APP_INSTALLATION_ID=
|
package/notes/api/README.md
CHANGED
|
@@ -7,7 +7,7 @@ FastAPI バックエンド。docs/ のドキュメント閲覧・編集、GitHub
|
|
|
7
7
|
- Python 3.12+
|
|
8
8
|
- uv
|
|
9
9
|
- PostgreSQL 16(GoDD Registry と共有)
|
|
10
|
-
- GitHub
|
|
10
|
+
- GitHub App credentials(`GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` / `GITHUB_APP_INSTALLATION_ID`)— 本番 GitHub 連携時。未設定時はモックモード
|
|
11
11
|
|
|
12
12
|
## セットアップ
|
|
13
13
|
|
|
@@ -39,10 +39,10 @@ uv run python -m app.main
|
|
|
39
39
|
|
|
40
40
|
| 変数 | 必須 | デフォルト | 説明 |
|
|
41
41
|
|------|------|-----------|------|
|
|
42
|
-
| GITHUB_TOKEN | - | (未設定=Mock) | GitHub API PAT(任意。GitHub App 未設定時の write 操作フォールバック) |
|
|
43
42
|
| GITHUB_APP_ID | **Yes** (write) | - | GitHub App ID。PR / Issue 作成時の Bot author に必須 |
|
|
44
43
|
| GITHUB_APP_PRIVATE_KEY | **Yes** (write) | - | GitHub App private key PEM |
|
|
45
44
|
| GITHUB_APP_INSTALLATION_ID | **Yes** (write) | - | GitHub App installation ID |
|
|
45
|
+
| GITHUB_TOKEN | — | — | **禁止(無視)**。PAT は使用不可。設定しても Notes API は参照しない |
|
|
46
46
|
| GITHUB_OWNER | - | ripla-inc | リポジトリオーナー |
|
|
47
47
|
| GITHUB_REPO | - | GoDD | リポジトリ名 |
|
|
48
48
|
| GITHUB_BRANCH | - | main | ベースブランチ |
|
|
@@ -52,6 +52,8 @@ uv run python -m app.main
|
|
|
52
52
|
| NOTES_ADMIN_USERNAME | - | admin | 初期管理者 |
|
|
53
53
|
| NOTES_ADMIN_PASSWORD | - | admin123 | 初期管理者パスワード |
|
|
54
54
|
|
|
55
|
+
GitHub App 3変数がすべて未設定の場合、Notes API は **モックモード**(GitHub API を呼ばない)で動作します。`GITHUB_TOKEN`(PAT)を設定しても **無視されます**(禁止)。
|
|
56
|
+
|
|
55
57
|
## API
|
|
56
58
|
|
|
57
59
|
- `GET /api/health` - ヘルスチェック(`github_app_configured`, `github_write_author`)
|
package/notes/api/app/config.py
CHANGED
|
@@ -73,8 +73,8 @@ class Settings(BaseSettings):
|
|
|
73
73
|
jwt_access_expires_min: int = Field(default=15, ge=1, le=1440)
|
|
74
74
|
jwt_refresh_expires_days: int = Field(default=7, ge=1, le=365)
|
|
75
75
|
|
|
76
|
-
# GitHub
|
|
77
|
-
github_token: str | None = None
|
|
76
|
+
# GitHub (PAT / GITHUB_TOKEN is forbidden — ignored if set)
|
|
77
|
+
github_token: str | None = None # deprecated: ignored; use GitHub App only
|
|
78
78
|
github_owner: str = "ripla-inc"
|
|
79
79
|
github_repo: str = "GoDD"
|
|
80
80
|
github_branch: str = "main"
|
|
@@ -150,9 +150,7 @@ class Settings(BaseSettings):
|
|
|
150
150
|
)
|
|
151
151
|
|
|
152
152
|
def is_mock_mode(self) -> bool:
|
|
153
|
-
"""True when
|
|
154
|
-
if self.github_token:
|
|
155
|
-
return False
|
|
153
|
+
"""True when GitHub App credentials are not set (PAT is ignored/forbidden)."""
|
|
156
154
|
return not (
|
|
157
155
|
self.github_app_id
|
|
158
156
|
and self.github_app_private_key
|
|
@@ -176,6 +174,11 @@ class Settings(BaseSettings):
|
|
|
176
174
|
warnings.append("DB_PASSWORD is using the default dev value")
|
|
177
175
|
if self.allowed_origins == "*":
|
|
178
176
|
warnings.append("ALLOWED_ORIGINS is set to '*' (all origins)")
|
|
177
|
+
if self.github_token:
|
|
178
|
+
warnings.append(
|
|
179
|
+
"GITHUB_TOKEN is set but ignored — PAT is forbidden; "
|
|
180
|
+
"use GitHub App credentials only"
|
|
181
|
+
)
|
|
179
182
|
for w in warnings:
|
|
180
183
|
_logger.warning("SECURITY: %s — override via environment variable for production", w)
|
|
181
184
|
return self
|
|
@@ -19,12 +19,12 @@ async def get_tree(
|
|
|
19
19
|
ref: str | None = None,
|
|
20
20
|
_payload: dict = Depends(auth_dependency),
|
|
21
21
|
):
|
|
22
|
-
"""Get file tree. Pass *ref* to read a specific branch. Mock mode when
|
|
22
|
+
"""Get file tree. Pass *ref* to read a specific branch. Mock mode when GitHub App is not set."""
|
|
23
23
|
if settings.is_mock_mode():
|
|
24
24
|
return {
|
|
25
25
|
"mode": "mock",
|
|
26
26
|
"source": "mock",
|
|
27
|
-
"reason": "
|
|
27
|
+
"reason": "GitHub App が未設定です",
|
|
28
28
|
"tree": get_mock_tree(),
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -38,7 +38,7 @@ async def get_tree(
|
|
|
38
38
|
logger.warning("GitHub API HTTP error %d for tree: %s", code, e)
|
|
39
39
|
if code in (401, 403):
|
|
40
40
|
detail = (
|
|
41
|
-
"GitHub
|
|
41
|
+
"GitHub App の権限が不足しています。"
|
|
42
42
|
"Organization の承認が必要な場合があります。"
|
|
43
43
|
)
|
|
44
44
|
elif code == 404:
|
|
@@ -18,15 +18,15 @@ def _encode_path(path: str) -> str:
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
async def resolve_token(config: dict) -> str:
|
|
21
|
-
"""Return a
|
|
21
|
+
"""Return a GitHub App installation token. PAT is not allowed."""
|
|
22
22
|
from app.services.github_app_token import get_installation_token, is_app_configured
|
|
23
23
|
|
|
24
|
-
if is_app_configured():
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
24
|
+
if not is_app_configured():
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"GitHub App credentials are required. "
|
|
27
|
+
"Personal access tokens (GITHUB_TOKEN / PAT) are not allowed."
|
|
28
|
+
)
|
|
29
|
+
return await get_installation_token()
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _auth_headers(token: str) -> dict[str, str]:
|
|
@@ -54,13 +54,14 @@ def get_github_config() -> dict:
|
|
|
54
54
|
"""Get GitHub config from settings."""
|
|
55
55
|
from app.services.github_app_token import is_app_configured
|
|
56
56
|
|
|
57
|
-
if not
|
|
57
|
+
if not is_app_configured():
|
|
58
58
|
raise ValueError(
|
|
59
|
-
"
|
|
60
|
-
"Set
|
|
59
|
+
"GitHub App credentials are required. "
|
|
60
|
+
"Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and "
|
|
61
|
+
"GITHUB_APP_INSTALLATION_ID. Personal access tokens (PAT) are not allowed."
|
|
61
62
|
)
|
|
62
63
|
return {
|
|
63
|
-
"token":
|
|
64
|
+
"token": "",
|
|
64
65
|
"owner": settings.github_owner,
|
|
65
66
|
"repo": settings.github_repo,
|
|
66
67
|
"branch": settings.github_branch,
|
|
@@ -104,8 +105,8 @@ def get_mock_file_content(file_path: str) -> dict:
|
|
|
104
105
|
"content": (
|
|
105
106
|
f"# {file_path}\n\n"
|
|
106
107
|
"このファイルはモックデータです。\n\n"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
108
|
+
"GitHub App(GITHUB_APP_ID / PRIVATE_KEY / INSTALLATION_ID)を"
|
|
109
|
+
"設定すると、実際のリポジトリからドキュメントが読み込まれます。"
|
|
109
110
|
),
|
|
110
111
|
"sha": "mock-sha",
|
|
111
112
|
"size": 100,
|
|
@@ -13,7 +13,7 @@ from app.config import settings
|
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
-
GitHubWriteAuthor = Literal["bot", "
|
|
16
|
+
GitHubWriteAuthor = Literal["bot", "mock"]
|
|
17
17
|
|
|
18
18
|
_TOKEN_BUFFER_SECONDS = 300
|
|
19
19
|
_JWT_EXPIRY_SECONDS = 600
|
|
@@ -35,30 +35,25 @@ def github_write_author_mode() -> GitHubWriteAuthor:
|
|
|
35
35
|
"""How PR/Issue creation will attribute authorship."""
|
|
36
36
|
if is_app_configured():
|
|
37
37
|
return "bot"
|
|
38
|
-
if settings.github_token:
|
|
39
|
-
return "pat"
|
|
40
38
|
return "mock"
|
|
41
39
|
|
|
42
40
|
|
|
43
|
-
def
|
|
44
|
-
"""Warn when a GitHub write
|
|
41
|
+
def warn_if_github_app_missing(operation: str) -> None:
|
|
42
|
+
"""Warn when a GitHub write cannot use GitHub App (PAT is forbidden)."""
|
|
45
43
|
if is_app_configured():
|
|
46
44
|
return
|
|
47
|
-
if settings.github_token:
|
|
48
|
-
logger.warning(
|
|
49
|
-
"GitHub App is not configured. %s will be created under the "
|
|
50
|
-
"personal account of the GITHUB_TOKEN owner. "
|
|
51
|
-
"Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and "
|
|
52
|
-
"GITHUB_APP_INSTALLATION_ID to use the Bot account as author.",
|
|
53
|
-
operation,
|
|
54
|
-
)
|
|
55
|
-
return
|
|
56
45
|
logger.warning(
|
|
57
|
-
"GitHub App is not configured
|
|
46
|
+
"GitHub App is not configured. %s will fail or run in mock mode. "
|
|
47
|
+
"Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and "
|
|
48
|
+
"GITHUB_APP_INSTALLATION_ID. Personal access tokens (PAT) are not allowed.",
|
|
58
49
|
operation,
|
|
59
50
|
)
|
|
60
51
|
|
|
61
52
|
|
|
53
|
+
# Backward-compatible alias (PAT path removed; always warns when App missing).
|
|
54
|
+
warn_if_pat_used_for_write = warn_if_github_app_missing
|
|
55
|
+
|
|
56
|
+
|
|
62
57
|
def _generate_jwt() -> str:
|
|
63
58
|
now = int(time.time())
|
|
64
59
|
payload = {
|
|
@@ -18,10 +18,13 @@ logger = logging.getLogger(__name__)
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _config() -> dict:
|
|
21
|
-
if not
|
|
22
|
-
raise ValueError(
|
|
21
|
+
if not is_app_configured():
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"GitHub App credentials required. "
|
|
24
|
+
"Personal access tokens (PAT) are not allowed."
|
|
25
|
+
)
|
|
23
26
|
return {
|
|
24
|
-
"token":
|
|
27
|
+
"token": "",
|
|
25
28
|
"owner": settings.github_owner,
|
|
26
29
|
"repo": settings.github_repo,
|
|
27
30
|
"branch": settings.github_branch,
|
|
@@ -31,10 +34,13 @@ def _config() -> dict:
|
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
async def _resolve_token(config: dict) -> str:
|
|
34
|
-
"""Return the GitHub App installation token
|
|
35
|
-
if is_app_configured():
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
"""Return the GitHub App installation token. PAT is not allowed."""
|
|
38
|
+
if not is_app_configured():
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"GitHub App credentials required. "
|
|
41
|
+
"Personal access tokens (PAT) are not allowed."
|
|
42
|
+
)
|
|
43
|
+
return await get_installation_token()
|
|
38
44
|
|
|
39
45
|
|
|
40
46
|
def _headers_with_token(token: str) -> dict:
|
|
@@ -47,7 +53,7 @@ def _headers_with_token(token: str) -> dict:
|
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
async def _resolved_headers(config: dict) -> dict:
|
|
50
|
-
"""Resolve
|
|
56
|
+
"""Resolve GitHub App installation token and return auth headers."""
|
|
51
57
|
token = await _resolve_token(config)
|
|
52
58
|
return _headers_with_token(token)
|
|
53
59
|
|
|
@@ -15,7 +15,7 @@ class TestGetFile:
|
|
|
15
15
|
self._headers = auth_headers(viewer_token)
|
|
16
16
|
|
|
17
17
|
async def test_get_file_mock_mode(self, client: AsyncClient):
|
|
18
|
-
"""Without
|
|
18
|
+
"""Without GitHub App credentials, returns mock file content."""
|
|
19
19
|
resp = await client.get("/api/files/docs/README.md", headers=self._headers)
|
|
20
20
|
assert resp.status_code == 200
|
|
21
21
|
data = resp.json()
|
|
@@ -49,7 +49,7 @@ class TestGetHistory:
|
|
|
49
49
|
self._headers = auth_headers(viewer_token)
|
|
50
50
|
|
|
51
51
|
async def test_history_mock_mode(self, client: AsyncClient):
|
|
52
|
-
"""Without
|
|
52
|
+
"""Without GitHub App credentials, history returns empty list."""
|
|
53
53
|
resp = await client.get("/api/history/docs/README.md", headers=self._headers)
|
|
54
54
|
assert resp.status_code == 200
|
|
55
55
|
assert resp.json() == []
|
|
@@ -50,9 +50,10 @@ class TestGithubWriteAuthorMode:
|
|
|
50
50
|
|
|
51
51
|
@patch("app.services.github_app_token.is_app_configured", return_value=False)
|
|
52
52
|
@patch("app.services.github_app_token.settings")
|
|
53
|
-
def
|
|
53
|
+
def test_mock_when_pat_only_pat_forbidden(self, mock_settings, _mock_app):
|
|
54
|
+
"""PAT alone must not enable write mode — mock only."""
|
|
54
55
|
mock_settings.github_token = "ghp_test"
|
|
55
|
-
assert github_write_author_mode() == "
|
|
56
|
+
assert github_write_author_mode() == "mock"
|
|
56
57
|
|
|
57
58
|
@patch("app.services.github_app_token.is_app_configured", return_value=False)
|
|
58
59
|
@patch("app.services.github_app_token.settings")
|
|
@@ -133,13 +134,13 @@ class TestWarnIfPatUsedForWrite:
|
|
|
133
134
|
|
|
134
135
|
@patch("app.services.github_app_token.is_app_configured", return_value=False)
|
|
135
136
|
@patch("app.services.github_app_token.settings")
|
|
136
|
-
def
|
|
137
|
+
def test_warns_when_app_missing_even_if_pat_set(self, mock_settings, _mock_app):
|
|
137
138
|
mock_settings.github_token = "ghp_test"
|
|
138
139
|
with patch("app.services.github_app_token.logger") as mock_logger:
|
|
139
140
|
warn_if_pat_used_for_write("Issue")
|
|
140
141
|
mock_logger.warning.assert_called_once()
|
|
141
142
|
message = mock_logger.warning.call_args[0][0]
|
|
142
|
-
assert "
|
|
143
|
+
assert "not allowed" in message or "PAT" in message
|
|
143
144
|
|
|
144
145
|
@patch("app.services.github_app_token.is_app_configured", return_value=False)
|
|
145
146
|
@patch("app.services.github_app_token.settings")
|
|
@@ -149,4 +150,4 @@ class TestWarnIfPatUsedForWrite:
|
|
|
149
150
|
warn_if_pat_used_for_write("Pull request")
|
|
150
151
|
mock_logger.warning.assert_called_once()
|
|
151
152
|
message = mock_logger.warning.call_args[0][0]
|
|
152
|
-
assert "
|
|
153
|
+
assert "mock mode" in message or "not allowed" in message
|
|
@@ -10,6 +10,32 @@ from app.services.github_issues import create_issue
|
|
|
10
10
|
from app.services.github_pr import _paginate_pulls, compare_branches
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _mock_github_app_token():
|
|
15
|
+
"""PAT is forbidden — unit tests use GitHub App installation token mocks."""
|
|
16
|
+
with (
|
|
17
|
+
patch(
|
|
18
|
+
"app.services.github_app_token.is_app_configured",
|
|
19
|
+
return_value=True,
|
|
20
|
+
),
|
|
21
|
+
patch(
|
|
22
|
+
"app.services.github_app_token.get_installation_token",
|
|
23
|
+
new_callable=AsyncMock,
|
|
24
|
+
return_value="ghs_test",
|
|
25
|
+
),
|
|
26
|
+
patch(
|
|
27
|
+
"app.services.github_pr.is_app_configured",
|
|
28
|
+
return_value=True,
|
|
29
|
+
),
|
|
30
|
+
patch(
|
|
31
|
+
"app.services.github_pr.get_installation_token",
|
|
32
|
+
new_callable=AsyncMock,
|
|
33
|
+
return_value="ghs_test",
|
|
34
|
+
),
|
|
35
|
+
):
|
|
36
|
+
yield
|
|
37
|
+
|
|
38
|
+
|
|
13
39
|
class TestIsBinary:
|
|
14
40
|
def test_xlsx_is_binary(self):
|
|
15
41
|
assert _is_binary("docs/data.xlsx") is True
|
|
@@ -217,17 +243,17 @@ class TestPaginatePullsDedup:
|
|
|
217
243
|
assert [pr["id"] for pr in result] == [10, 20]
|
|
218
244
|
|
|
219
245
|
|
|
220
|
-
class
|
|
246
|
+
class TestCreateIssueAppWarning:
|
|
221
247
|
@pytest.fixture
|
|
222
248
|
def github_config(self):
|
|
223
249
|
return {
|
|
224
250
|
"owner": "testorg",
|
|
225
251
|
"repo": "testrepo",
|
|
226
252
|
"branch": "main",
|
|
227
|
-
"token": "
|
|
253
|
+
"token": "",
|
|
228
254
|
}
|
|
229
255
|
|
|
230
|
-
async def
|
|
256
|
+
async def test_create_issue_warns_when_app_missing(self, github_config):
|
|
231
257
|
mock_resp = MagicMock()
|
|
232
258
|
mock_resp.raise_for_status = MagicMock()
|
|
233
259
|
mock_resp.json.return_value = {
|
|
@@ -249,7 +275,7 @@ class TestCreateIssuePatWarning:
|
|
|
249
275
|
patch(
|
|
250
276
|
"app.services.github_issues.resolve_token",
|
|
251
277
|
new_callable=AsyncMock,
|
|
252
|
-
return_value="
|
|
278
|
+
return_value="ghs_test",
|
|
253
279
|
),
|
|
254
280
|
patch("app.services.github_issues.warn_if_pat_used_for_write") as mock_warn,
|
|
255
281
|
):
|
|
@@ -23,7 +23,7 @@ from app.services.pr_chain import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
FAKE_CONFIG = {
|
|
26
|
-
"token": "
|
|
26
|
+
"token": "",
|
|
27
27
|
"owner": "test-owner",
|
|
28
28
|
"repo": "test-repo",
|
|
29
29
|
"branch": "main",
|
|
@@ -39,6 +39,23 @@ FAKE_PR = {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
@pytest.fixture(autouse=True)
|
|
43
|
+
def _mock_github_app_token():
|
|
44
|
+
"""PAT is forbidden — unit tests use GitHub App installation token mocks."""
|
|
45
|
+
with (
|
|
46
|
+
patch(
|
|
47
|
+
"app.services.github_pr.is_app_configured",
|
|
48
|
+
return_value=True,
|
|
49
|
+
),
|
|
50
|
+
patch(
|
|
51
|
+
"app.services.github_pr.get_installation_token",
|
|
52
|
+
new_callable=AsyncMock,
|
|
53
|
+
return_value="ghs_test",
|
|
54
|
+
),
|
|
55
|
+
):
|
|
56
|
+
yield
|
|
57
|
+
|
|
58
|
+
|
|
42
59
|
class TestFindPrByHead:
|
|
43
60
|
@pytest.mark.asyncio
|
|
44
61
|
async def test_returns_pr_when_found(self):
|
|
@@ -530,7 +547,7 @@ class TestCreatePrDraftFlag:
|
|
|
530
547
|
assert call_json["draft"] is True
|
|
531
548
|
|
|
532
549
|
@pytest.mark.asyncio
|
|
533
|
-
async def
|
|
550
|
+
async def test_create_pr_warns_when_app_missing(self):
|
|
534
551
|
mock_resp = MagicMock()
|
|
535
552
|
mock_resp.status_code = 201
|
|
536
553
|
mock_resp.json.return_value = {**FAKE_PR, "draft": True}
|
|
@@ -542,7 +559,13 @@ class TestCreatePrDraftFlag:
|
|
|
542
559
|
with (
|
|
543
560
|
patch("app.services.github_pr.get_github_client", return_value=mock_client),
|
|
544
561
|
patch("app.services.github_pr.is_app_configured", return_value=False),
|
|
562
|
+
patch(
|
|
563
|
+
"app.services.github_pr.get_installation_token",
|
|
564
|
+
new_callable=AsyncMock,
|
|
565
|
+
return_value="ghs_test",
|
|
566
|
+
),
|
|
545
567
|
patch("app.services.github_pr.warn_if_pat_used_for_write") as mock_warn,
|
|
568
|
+
pytest.raises(ValueError, match="GitHub App credentials required"),
|
|
546
569
|
):
|
|
547
570
|
await create_pr("title", "head", "main", "body", [], FAKE_CONFIG)
|
|
548
571
|
|
|
@@ -16,7 +16,7 @@ class TestTree:
|
|
|
16
16
|
self._headers = auth_headers(viewer_token)
|
|
17
17
|
|
|
18
18
|
async def test_get_tree_mock_mode(self, client: AsyncClient):
|
|
19
|
-
"""Without
|
|
19
|
+
"""Without GitHub App credentials, tree returns mock data with source field."""
|
|
20
20
|
resp = await client.get("/api/tree", headers=self._headers)
|
|
21
21
|
assert resp.status_code == 200
|
|
22
22
|
data = resp.json()
|
|
@@ -9,12 +9,18 @@ export class LoginPage {
|
|
|
9
9
|
readonly passwordInput: Locator;
|
|
10
10
|
readonly submitButton: Locator;
|
|
11
11
|
readonly errorAlert: Locator;
|
|
12
|
+
/** パスワード表示/非表示トグル(PasswordInput)。aria-label は表示状態で変わるため両方に一致させる。 */
|
|
13
|
+
readonly passwordToggle: Locator;
|
|
14
|
+
/** トグル状態を通知する sr-only ライブリージョン(PasswordInput)。 */
|
|
15
|
+
readonly passwordStatus: Locator;
|
|
12
16
|
|
|
13
17
|
constructor(private readonly page: Page) {
|
|
14
18
|
this.usernameInput = page.locator("#login-username");
|
|
15
19
|
this.passwordInput = page.locator("#login-password");
|
|
16
20
|
this.submitButton = page.locator('button[type="submit"]');
|
|
17
21
|
this.errorAlert = page.locator('[role="alert"]');
|
|
22
|
+
this.passwordToggle = page.getByRole("button", { name: /^パスワードを(非表示|表示)$/ });
|
|
23
|
+
this.passwordStatus = page.getByRole("status");
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
async goto(): Promise<void> {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { test, expect, TEST_USERNAME, TEST_PASSWORD } from "./fixtures";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* REQ-UI-AC-13 / REQ-UI-AC-14 / REQ-UI-AC-17(#1140): ログイン画面のパスワード表示トグル
|
|
5
|
+
* (`PasswordInput`)を実ブラウザ(Playwright)で検証する。
|
|
6
|
+
*
|
|
7
|
+
* 既存の `src/components/PasswordInput.test.tsx` は jsdom のため **className トークン一致**までしか
|
|
8
|
+
* 検証できない(`pe-10` が付いていること/`focus-visible:ring-2` が付いていること)。以下は jsdom では
|
|
9
|
+
* 原理的に検証不可能で、本 spec でのみガードできる:
|
|
10
|
+
*
|
|
11
|
+
* - `pe-10` が実際にアイコンぶんの領域を確保し、トグルボタンが入力欄のテキスト領域に**重ならない**こと
|
|
12
|
+
* - 絶対配置(`absolute inset-y-0 end-0`)のトグルボタンが入力欄への**クリックを奪わない**こと(ヒットテスト)
|
|
13
|
+
* - `outline-none` + `focus-visible:ring-2` が実際にリングを描画し、マウス操作では描画しないこと
|
|
14
|
+
* - `type="button"` が実ブラウザで本当に submit を誘発しないこと(属性ではなく挙動として)
|
|
15
|
+
*
|
|
16
|
+
* ログイン画面は未認証状態が前提のため、`login.spec.ts` と同じく storageState を空にする。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// 幾何比較の許容差(px)。サブピクセル丸めを吸収する小さな値。
|
|
20
|
+
const GEOMETRY_TOLERANCE_PX = 1;
|
|
21
|
+
|
|
22
|
+
test.describe("パスワード表示トグル (REQ-UI-AC-13/14/17 / #1140)", () => {
|
|
23
|
+
test.use({ storageState: { cookies: [], origins: [] } });
|
|
24
|
+
// トグルの絶対配置は viewport 幅に依存しないが、幾何比較を安定させるため明示する。
|
|
25
|
+
test.use({ viewport: { width: 1280, height: 720 } });
|
|
26
|
+
|
|
27
|
+
test("初期状態では伏字(type=password)で描画される", async ({ loginPage }) => {
|
|
28
|
+
await loginPage.goto();
|
|
29
|
+
|
|
30
|
+
await expect(loginPage.passwordInput).toHaveAttribute("type", "password");
|
|
31
|
+
await expect(loginPage.passwordToggle).toHaveAccessibleName("パスワードを表示");
|
|
32
|
+
// 初回描画では何も起きていないため通知しない。
|
|
33
|
+
await expect(loginPage.passwordStatus).toHaveText("");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("トグルで平文表示に切り替わり、再クリックで伏字に戻る", async ({ loginPage }) => {
|
|
37
|
+
await loginPage.goto();
|
|
38
|
+
await loginPage.passwordInput.fill(TEST_PASSWORD);
|
|
39
|
+
|
|
40
|
+
await loginPage.passwordToggle.click();
|
|
41
|
+
await expect(loginPage.passwordInput).toHaveAttribute("type", "text");
|
|
42
|
+
await expect(loginPage.passwordToggle).toHaveAccessibleName("パスワードを非表示");
|
|
43
|
+
await expect(loginPage.passwordStatus).toHaveText("パスワードを表示しました");
|
|
44
|
+
// 表示切替で値が失われないこと。
|
|
45
|
+
await expect(loginPage.passwordInput).toHaveValue(TEST_PASSWORD);
|
|
46
|
+
|
|
47
|
+
await loginPage.passwordToggle.click();
|
|
48
|
+
await expect(loginPage.passwordInput).toHaveAttribute("type", "password");
|
|
49
|
+
await expect(loginPage.passwordToggle).toHaveAccessibleName("パスワードを表示");
|
|
50
|
+
await expect(loginPage.passwordStatus).toHaveText("パスワードを非表示にしました");
|
|
51
|
+
await expect(loginPage.passwordInput).toHaveValue(TEST_PASSWORD);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("トグルボタンはフォームを送信しない(type=button の実挙動)", async ({ page, loginPage }) => {
|
|
55
|
+
await loginPage.goto();
|
|
56
|
+
// 有効な認証情報を入れておく。誤って submit されれば遷移してしまうため、
|
|
57
|
+
// 「/login に留まる」ことが type=button の強い証拠になる。
|
|
58
|
+
await loginPage.usernameInput.fill(TEST_USERNAME);
|
|
59
|
+
await loginPage.passwordInput.fill(TEST_PASSWORD);
|
|
60
|
+
|
|
61
|
+
await page.evaluate(() => {
|
|
62
|
+
document.querySelector("form")?.addEventListener("submit", () => {
|
|
63
|
+
(window as unknown as { __submitted?: boolean }).__submitted = true;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await loginPage.passwordToggle.click();
|
|
68
|
+
await expect(loginPage.passwordInput).toHaveAttribute("type", "text");
|
|
69
|
+
|
|
70
|
+
const submitted = await page.evaluate(
|
|
71
|
+
() => (window as unknown as { __submitted?: boolean }).__submitted === true,
|
|
72
|
+
);
|
|
73
|
+
expect(submitted).toBe(false);
|
|
74
|
+
await expect(page).toHaveURL(/\/login/);
|
|
75
|
+
await expect(loginPage.errorAlert).toHaveCount(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("pe-10 がアイコン領域を確保し、トグルが入力テキスト領域に重ならない", async ({
|
|
79
|
+
loginPage,
|
|
80
|
+
}) => {
|
|
81
|
+
await loginPage.goto();
|
|
82
|
+
|
|
83
|
+
// 入力欄のコンテンツボックス右端(border + padding-inline-end を除いた、文字が描画され得る右端)。
|
|
84
|
+
const contentRight = await loginPage.passwordInput.evaluate((el) => {
|
|
85
|
+
const r = el.getBoundingClientRect();
|
|
86
|
+
const cs = getComputedStyle(el);
|
|
87
|
+
return (
|
|
88
|
+
r.right - Number.parseFloat(cs.paddingInlineEnd) - Number.parseFloat(cs.borderRightWidth)
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const toggleBox = await loginPage.passwordToggle.boundingBox();
|
|
93
|
+
if (!toggleBox) throw new Error("トグルボタンの boundingBox を取得できない");
|
|
94
|
+
|
|
95
|
+
// padding が実効していれば、ボタンの左端はコンテンツボックス右端以降にある。
|
|
96
|
+
// `pe-10` が失われるとボタンが文字領域に食い込み、この比較が red になる。
|
|
97
|
+
expect(toggleBox.x).toBeGreaterThanOrEqual(contentRight - GEOMETRY_TOLERANCE_PX);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("トグルボタンは入力欄へのクリックを奪わない(ヒットテスト)", async ({
|
|
101
|
+
page,
|
|
102
|
+
loginPage,
|
|
103
|
+
}) => {
|
|
104
|
+
await loginPage.goto();
|
|
105
|
+
|
|
106
|
+
const inputBox = await loginPage.passwordInput.boundingBox();
|
|
107
|
+
if (!inputBox) throw new Error("パスワード入力欄の boundingBox を取得できない");
|
|
108
|
+
|
|
109
|
+
// 入力欄のテキスト領域(左寄り)をクリックすると入力欄がフォーカスされる。
|
|
110
|
+
await page.mouse.click(inputBox.x + 20, inputBox.y + inputBox.height / 2);
|
|
111
|
+
await expect(loginPage.passwordInput).toBeFocused();
|
|
112
|
+
// 入力欄が押されただけで表示状態は変わらない。
|
|
113
|
+
await expect(loginPage.passwordInput).toHaveAttribute("type", "password");
|
|
114
|
+
|
|
115
|
+
// 逆にアイコン位置のクリックは確実にボタンへ届く。
|
|
116
|
+
const toggleBox = await loginPage.passwordToggle.boundingBox();
|
|
117
|
+
if (!toggleBox) throw new Error("トグルボタンの boundingBox を取得できない");
|
|
118
|
+
await page.mouse.click(
|
|
119
|
+
toggleBox.x + toggleBox.width / 2,
|
|
120
|
+
toggleBox.y + toggleBox.height / 2,
|
|
121
|
+
);
|
|
122
|
+
await expect(loginPage.passwordInput).toHaveAttribute("type", "text");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("キーボードフォーカス時のみ focus-visible リングを描画する", async ({
|
|
126
|
+
page,
|
|
127
|
+
loginPage,
|
|
128
|
+
}) => {
|
|
129
|
+
await loginPage.goto();
|
|
130
|
+
|
|
131
|
+
// マウスクリックでは :focus-visible にならず、リングも描画されない(outline-none のまま)。
|
|
132
|
+
await loginPage.passwordToggle.click();
|
|
133
|
+
const shadowAfterMouse = await loginPage.passwordToggle.evaluate(
|
|
134
|
+
(el) => getComputedStyle(el).boxShadow,
|
|
135
|
+
);
|
|
136
|
+
expect(shadowAfterMouse).toBe("none");
|
|
137
|
+
|
|
138
|
+
// パスワード入力欄から Tab するとトグルへキーボードフォーカスが移り、リングが描画される。
|
|
139
|
+
await loginPage.passwordInput.focus();
|
|
140
|
+
await page.keyboard.press("Tab");
|
|
141
|
+
await expect(loginPage.passwordToggle).toBeFocused();
|
|
142
|
+
|
|
143
|
+
const shadowAfterKeyboard = await loginPage.passwordToggle.evaluate(
|
|
144
|
+
(el) => getComputedStyle(el).boxShadow,
|
|
145
|
+
);
|
|
146
|
+
// `outline-none` で既定リングを消しているため、代替リングの実描画が必須(WCAG 2.4.11 / REQ-UI-AC-11)。
|
|
147
|
+
expect(shadowAfterKeyboard).not.toBe("none");
|
|
148
|
+
// ring-inset。外向きリングだと入力欄の丸角(rounded-lg)から角張って食み出す。
|
|
149
|
+
expect(shadowAfterKeyboard).toContain("inset");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// REQ-UI-AC-12 と同じ方針: jsdom は論理プロパティの実反転(物理側の特定)を検証できないため、
|
|
153
|
+
// `pe-10` / `end-0` が RTL で正しく反転することは E2E でのみガードできる(#1043)。
|
|
154
|
+
test("RTL ではトグルと入力欄パディングが揃って inline-end 側へ反転する", async ({
|
|
155
|
+
page,
|
|
156
|
+
loginPage,
|
|
157
|
+
}) => {
|
|
158
|
+
await loginPage.goto();
|
|
159
|
+
|
|
160
|
+
const measure = () =>
|
|
161
|
+
loginPage.passwordInput.evaluate((el) => {
|
|
162
|
+
const cs = getComputedStyle(el);
|
|
163
|
+
return {
|
|
164
|
+
left: el.getBoundingClientRect().left,
|
|
165
|
+
right: el.getBoundingClientRect().right,
|
|
166
|
+
paddingLeft: Number.parseFloat(cs.paddingLeft),
|
|
167
|
+
paddingRight: Number.parseFloat(cs.paddingRight),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const ltrInput = await measure();
|
|
172
|
+
const ltrToggle = await loginPage.passwordToggle.boundingBox();
|
|
173
|
+
if (!ltrToggle) throw new Error("トグルボタンの boundingBox を取得できない");
|
|
174
|
+
|
|
175
|
+
// LTR: アイコンは物理右、40px のパディングも物理右。
|
|
176
|
+
expect(ltrToggle.x).toBeGreaterThan(ltrInput.left + ltrInput.right - ltrInput.left - 60);
|
|
177
|
+
expect(ltrInput.paddingRight).toBeGreaterThan(ltrInput.paddingLeft);
|
|
178
|
+
|
|
179
|
+
await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl"));
|
|
180
|
+
|
|
181
|
+
const rtlInput = await measure();
|
|
182
|
+
const rtlToggle = await loginPage.passwordToggle.boundingBox();
|
|
183
|
+
if (!rtlToggle) throw new Error("RTL 時のトグル boundingBox を取得できない");
|
|
184
|
+
|
|
185
|
+
// 入力欄そのものの位置は変わらない(レイアウトが崩れていないことの確認)。
|
|
186
|
+
expect(rtlInput.left).toBeCloseTo(ltrInput.left, 0);
|
|
187
|
+
expect(rtlInput.right).toBeCloseTo(ltrInput.right, 0);
|
|
188
|
+
|
|
189
|
+
// RTL: アイコンは物理左へ移り、40px のパディングも物理左へ揃って移動する。
|
|
190
|
+
// 物理プロパティ(right-0 / pr-10)に戻すと、この 2 つが逆側に分かれて red になる。
|
|
191
|
+
expect(rtlToggle.x).toBeLessThan(ltrToggle.x);
|
|
192
|
+
expect(rtlToggle.x).toBeCloseTo(rtlInput.left, 0);
|
|
193
|
+
expect(rtlInput.paddingLeft).toBeGreaterThan(rtlInput.paddingRight);
|
|
194
|
+
expect(rtlInput.paddingLeft).toBeCloseTo(ltrInput.paddingRight, 0);
|
|
195
|
+
|
|
196
|
+
await page.evaluate(() => document.documentElement.removeAttribute("dir"));
|
|
197
|
+
});
|
|
198
|
+
});
|