Compare commits

..

9 Commits

Author SHA1 Message Date
kjh2064 16f7c6097c test(e2e): disambiguate dashboard heading
TaxBaik CI/CD / build-and-deploy (push) Successful in 54s
2026-06-28 19:38:17 +09:00
kjh2064 7232635ed0 docs(ci): add deploy troubleshooting harness
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 19:34:23 +09:00
kjh2064 b42b98d560 fix(auth): return token alias for admin login 2026-06-28 19:34:22 +09:00
kjh2064 f216660afa fix(portal): skip unconfigured oauth providers
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s
2026-06-28 19:29:54 +09:00
kjh2064 b31b43e30e fix(ci): repair deploy workflow yaml
TaxBaik CI/CD / build-and-deploy (push) Failing after 1m45s
2026-06-28 19:25:40 +09:00
kjh2064 86bd9ef8ff chore(ci): allow manual deploy dispatch 2026-06-28 19:13:35 +09:00
kjh2064 2fd9984a45 chore(ci): trigger deploy after verification 2026-06-28 18:55:29 +09:00
kjh2064 91330ec94c chore(ci): trigger deploy with real push 2026-06-28 18:50:11 +09:00
kjh2064 08102c8684 chore(ci): deploy trigger 2026-06-28 18:42:55 +09:00
7 changed files with 136 additions and 70 deletions
+1
View File
@@ -9,3 +9,4 @@ Authentication__Naver__ClientId=
Authentication__Naver__ClientSecret= Authentication__Naver__ClientSecret=
Authentication__Kakao__ClientId= Authentication__Kakao__ClientId=
Authentication__Kakao__ClientSecret= Authentication__Kakao__ClientSecret=
# CI deploy trigger requires a real push on master.
+8 -7
View File
@@ -1,6 +1,7 @@
name: TaxBaik CI/CD name: TaxBaik CI/CD
on: on:
workflow_dispatch:
push: push:
branches: branches:
- master - master
@@ -130,9 +131,9 @@ jobs:
local exit_code=$? local exit_code=$?
send_telegram "❌ <b>TaxBaik 배포 실패</b> send_telegram "❌ <b>TaxBaik 배포 실패</b>
커밋: <code>${COMMIT}</code> 커밋: <code>${COMMIT}</code>
시간: <code>${TIMESTAMP}</code> 시간: <code>${TIMESTAMP}</code>
단계: CI/CD deploy" 단계: CI/CD deploy"
exit "$exit_code" exit "$exit_code"
} }
@@ -220,7 +221,7 @@ jobs:
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST" echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
send_telegram "✅ <b>TaxBaik 배포 완료</b> send_telegram "✅ <b>TaxBaik 배포 완료</b>
커밋: <code>${COMMIT}</code> 커밋: <code>${COMMIT}</code>
시간: <code>${TIMESTAMP}</code> 시간: <code>${TIMESTAMP}</code>
대상: <code>${DEPLOY_HOST}</code> 대상: <code>${DEPLOY_HOST}</code>
채널: <code>${TELEGRAM_CHAT_ID}</code>" 채널: <code>${TELEGRAM_CHAT_ID}</code>"
+42
View File
@@ -1931,6 +1931,48 @@ else
--- ---
### CI Deploy 트러블슈팅 하네스 (2026-06-28)
커밋 후 배포가 동작하지 않는다고 판단하기 전에 아래 순서로 확인한다. 추측으로 runner, secret, 커밋 제목을 원인으로 단정하지 않는다.
1. **푸시 결과 확인**
```powershell
git push origin master 2>&1 | Select-String "master|To|Processed|remote"
```
`master -> master`가 보이면 Git push는 성공이다. 이 단계는 CI 실행 성공을 의미하지 않는다.
2. **Actions run 생성 확인**
```powershell
$headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
$runs = Invoke-RestMethod -Headers $headers -Uri "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
$runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
```
`deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.
3. **workflow 파싱 검증**
```powershell
curl.exe -sS -w "`nHTTP_STATUS:%{http_code}`n" `
-H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
-H "Content-Type: application/json" `
-X POST "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/workflows/deploy.yml/dispatches?return_run_details=true" `
--data '{"ref":"refs/heads/master","inputs":{}}'
```
`failed to unmarshal workflow content`가 나오면 `.gitea/workflows/deploy.yml` YAML 문법 문제다. 여러 줄 문자열은 반드시 `run: |` 블록 들여쓰기 안에 둔다.
4. **job 실패 로그 확인**
```powershell
curl.exe -sS -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
"http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/jobs/{job_id}/logs"
```
빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.
**이번 장애 원인 기록**:
- `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다.
- 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다.
- 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다.
---
## 12. 문제 해결 ## 12. 문제 해결
| 문제 | 해결 | | 문제 | 해결 |
+2
View File
@@ -2,6 +2,8 @@
**온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보 **온라인 세무 상담 플랫폼** | 블로그 SEO 최적화 | 전국 고객 확보
CI deploy trigger verification note.
--- ---
## 개요 ## 개요
@@ -27,6 +27,7 @@ public class AuthController : ControllerBase
return Ok(new return Ok(new
{ {
token = tokenPair.AccessToken,
accessToken = tokenPair.AccessToken, accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken, refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn expiresIn = tokenPair.ExpiresIn
@@ -45,6 +46,7 @@ public class AuthController : ControllerBase
return Ok(new return Ok(new
{ {
token = tokenPair.AccessToken,
accessToken = tokenPair.AccessToken, accessToken = tokenPair.AccessToken,
refreshToken = tokenPair.RefreshToken, refreshToken = tokenPair.RefreshToken,
expiresIn = tokenPair.ExpiresIn expiresIn = tokenPair.ExpiresIn
+80 -62
View File
@@ -64,7 +64,7 @@ if (isProduction && jwtKey.Contains("dev-secret", StringComparison.OrdinalIgnore
throw new InvalidOperationException("Production JWT SecretKey must not use the development default."); throw new InvalidOperationException("Production JWT SecretKey must not use the development default.");
var key = Encoding.ASCII.GetBytes(jwtKey); var key = Encoding.ASCII.GetBytes(jwtKey);
builder.Services.AddAuthentication(opts => var authenticationBuilder = builder.Services.AddAuthentication(opts =>
{ {
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -100,69 +100,87 @@ builder.Services.AddAuthentication(opts =>
opts.Cookie.HttpOnly = true; opts.Cookie.HttpOnly = true;
opts.Cookie.SameSite = SameSiteMode.Lax; opts.Cookie.SameSite = SameSiteMode.Lax;
opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
})
.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "";
opts.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "";
opts.CallbackPath = "/taxbaik/portal/signin-google";
})
.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = builder.Configuration["Authentication:Naver:ClientId"] ?? "";
opts.ClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"] ?? "";
opts.CallbackPath = "/taxbaik/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var responseRoot = payload.RootElement.GetProperty("response");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
}
};
})
.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = builder.Configuration["Authentication:Kakao:ClientId"] ?? "";
opts.ClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"] ?? "";
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
var profile = kakaoAccount.GetProperty("profile");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
if (kakaoAccount.TryGetProperty("email", out var emailProp))
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
}
};
}); });
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
{
authenticationBuilder.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = googleClientId;
opts.ClientSecret = googleClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-google";
});
}
var naverClientId = builder.Configuration["Authentication:Naver:ClientId"];
var naverClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"];
if (!string.IsNullOrWhiteSpace(naverClientId) && !string.IsNullOrWhiteSpace(naverClientSecret))
{
authenticationBuilder.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = naverClientId;
opts.ClientSecret = naverClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-naver";
opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var responseRoot = payload.RootElement.GetProperty("response");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
}
};
});
}
var kakaoClientId = builder.Configuration["Authentication:Kakao:ClientId"];
var kakaoClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"];
if (!string.IsNullOrWhiteSpace(kakaoClientId) && !string.IsNullOrWhiteSpace(kakaoClientSecret))
{
authenticationBuilder.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
{
opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
opts.ClientId = kakaoClientId;
opts.ClientSecret = kakaoClientSecret;
opts.CallbackPath = "/taxbaik/portal/signin-kakao";
opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
opts.SaveTokens = true;
opts.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
var profile = kakaoAccount.GetProperty("profile");
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
if (kakaoAccount.TryGetProperty("email", out var emailProp))
context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
}
};
});
}
// Blazor 인증 // Blazor 인증
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CustomAuthenticationStateProvider>(); builder.Services.AddScoped<CustomAuthenticationStateProvider>();
+1 -1
View File
@@ -38,7 +38,7 @@ export async function loginThroughAdminUi(
await page.locator('input[placeholder="비밀번호"]').fill(password); await page.locator('input[placeholder="비밀번호"]').fill(password);
await page.getByRole('button', { name: '로그인' }).click(); await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/); await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('heading', { name: '대시보드' }).first()).toBeVisible({ timeout: 20_000 });
} }
export async function navigateInBlazor(page: Page, targetUrl: string) { export async function navigateInBlazor(page: Page, targetUrl: string) {