Files
creator-hub/internal/controlplane/api/app_migrated_test.go
T
rogee 0879d9fd25 refactor(schema): 迁移 043 schema 收敛——42→19 张表,统一迁移 runner
- 迁移 043 一次到位:runtime_instance/environment_binding 并入 browser_env(账号即环境)、
  credential_reference 并入 social_account、work_source/metric_plan 并入 creator_work、
  sync 租约并入 creator_collection_checkpoint(kind='sync')、audit_event 瘦列、
  creator_settings 去转写列、TRUNCATE 账号域(竞品采集数据无损保留)
- 统一迁移 runner:environment.Open 串起 001 账号基座 + hub 链 + 038 creator 版本回填
  (v+1000) + creator 1017-1042 + 043;account/creator Open 不再迁移
- 三域 store SQL 全量适配:CreateBoundEnv 就绪门禁、SetRuntimeCleanupPending fence
  语义(未知代 MissingRuntimeID 允许登记并释放活跃实例)、metric_plan 列并入 work、
  checkpoint sync 租约、deletion 链路重写
- api 适配:envView 去 runtime_instance_id、audit filter 去任务列、browser_env 单表查询
- 测试:迁移 043 形态探针、迁移不可变映射新路径、legacy 迁移重放子测试重写、
  fence/出口生命周期/单来源 upsert 语义覆盖;CREATORHUB_POSTGRES_TEST_URL 全绿,
  environment 70%/creator 67.5% 覆盖率,tsc 通过
2026-09-28 19:48:55 +08:00

463 lines
22 KiB
Go

package api
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
account "git.ipao.vip/rogee/creator-hub/internal/account"
"git.ipao.vip/rogee/creator-hub/internal/creator"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
func TestCreatorReadRoutesAgainstPostgres(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := account.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
for _, path := range []string{
"/api/creator/settings", "/api/creator/accounts", "/api/creator/competitors",
"/api/creator/rules", "/api/creator/rule-results", "/api/creator/leads",
"/api/creator/works", "/api/creator/works?page=1&page_size=10", "/api/creator/comments", "/api/creator/comments?page=1&page_size=10",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
}
func TestCreatorRouteValidationCoverage(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := account.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
credentials := &testCredentialBridge{values: make(map[string]string)}
if err := phaseAStore.CreateAccount(ctx, account.Account{
ID: "route-account", Name: "Route Account", Platform: creator.PlatformDouyin,
PlatformAccountKey: "route-platform", Tags: []string{}, Cookies: "",
CredentialReference: account.CredentialReference{ID: "route-account-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/route-account/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
for _, path := range []string{
"/api/creator/accounts/route-account/profile",
"/api/creator/accounts/route-account/collection-status", "/api/creator/accounts/route-account/metrics",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
for _, path := range []string{
"/api/creator/works/missing/metrics", "/api/creator/rule-results?comment_id=missing",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
for _, path := range []string{
"/api/creator/competitors/missing", "/api/creator/works/missing",
"/api/creator/comments/missing", "/api/creator/rules/missing",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusNotFound {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
for _, route := range []struct {
method string
path string
}{
{http.MethodPut, "/api/creator/settings"},
{http.MethodPut, "/api/creator/accounts/missing/profile"},
{http.MethodPost, "/api/creator/accounts/missing/login-result"},
{http.MethodPost, "/api/creator/accounts/missing/big-account"},
{http.MethodPost, "/api/creator/competitor-share-jobs"},
{http.MethodPost, "/api/creator/competitors/missing/pause"},
{http.MethodPost, "/api/creator/competitors/missing/resume"},
{http.MethodPost, "/api/creator/competitors/missing/sync"},
{http.MethodPost, "/api/creator/accounts/missing/sync"},
{http.MethodPost, "/api/creator/works/missing/metrics"},
{http.MethodPost, "/api/creator/rules"},
{http.MethodPut, "/api/creator/rules/missing"},
{http.MethodPost, "/api/creator/rules/missing/enable"},
{http.MethodPost, "/api/creator/rules/missing/disable"},
{http.MethodPost, "/api/creator/comments/analyze"},
{http.MethodPost, "/api/creator/comments/missing/analyze"},
{http.MethodPost, "/api/creator/events/missing/display"},
{http.MethodPost, "/api/creator/operations"},
{http.MethodPost, "/api/creator/operations/missing/execute"},
{http.MethodPost, "/api/creator/conversations/missing/sync"},
{http.MethodPost, "/api/creator/messages/send"},
} {
response := do(app, route.method, route.path, "{", "operator", "unit-test-password")
if response.Code != http.StatusBadRequest && response.Code != http.StatusNotFound && response.Code != http.StatusNoContent && response.Code != http.StatusServiceUnavailable {
t.Fatalf("%s %s returned %d for invalid JSON: %s", route.method, route.path, response.Code, response.Body.String())
}
}
for _, path := range []string{
"/api/creator/works", "/api/creator/comments", "/api/creator/events", "/api/creator/events/process", "/api/creator/messages",
} {
response := do(app, http.MethodPost, path, `{}`, "operator", "unit-test-password")
if response.Code != http.StatusConflict {
t.Fatalf("POST %s accepted public platform input: %d", path, response.Code)
}
}
}
func TestCreatorFixtureRoutesPostgres(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator fixture coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := account.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
credentials := &testCredentialBridge{values: make(map[string]string)}
if err := phaseAStore.CreateAccount(ctx, account.Account{
ID: "fixture-account", Name: "Fixture Account", Platform: creator.PlatformDouyin,
PlatformAccountKey: "fixture-platform", Tags: []string{}, Cookies: "",
CredentialReference: account.CredentialReference{ID: "fixture-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/fixture-account/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
if err := phaseAStore.CreateAccount(ctx, account.Account{
ID: "fixture-small", Name: "Fixture Small", Platform: creator.PlatformDouyin,
PlatformAccountKey: "fixture-small-platform", Tags: []string{}, Cookies: "",
CredentialReference: account.CredentialReference{ID: "fixture-small-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/fixture-small/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
if err := creatorStore.EnsureAccountProfile(ctx, "fixture-small"); err != nil {
t.Fatal(err)
}
app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
idFrom := func(response *httptest.ResponseRecorder) string {
var value struct {
ID string `json:"id"`
}
if err := json.Unmarshal(response.Body.Bytes(), &value); err != nil || value.ID == "" {
t.Fatalf("response has no ID: %s (%v)", response.Body.String(), err)
}
return value.ID
}
workBody := `{"platform":"douyin","work_key":"fixture-work","source_type":"owned","source_id":"fixture-account","author_name":"author","title":"title","body":"body","published_at":"2024-01-01T00:00:00Z","published_at_status":"verified"}`
workResponse := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password")
if workResponse.Code != http.StatusCreated {
t.Fatalf("create fixture work: %d %s", workResponse.Code, workResponse.Body.String())
}
workID := idFrom(workResponse)
if response := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("deduplicate fixture work: %d %s", response.Code, response.Body.String())
}
for _, path := range []string{"/api/creator/works/" + workID, "/api/creator/works/" + workID + "/metrics"} {
if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String())
}
}
metricBody := `{"likes":2,"comments_count":1,"shares":1}`
if response := do(app, http.MethodPost, "/api/creator/works/"+workID+"/metrics", metricBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("record fixture metric: %d %s", response.Code, response.Body.String())
}
commentBody := `{"platform":"douyin","comment_key":"fixture-comment","work_id":"` + workID + `","author_uid":"peer","author_name":"Peer","content":"hello","comment_type":"top_level","published_at":"2024-01-01T00:00:00Z"}`
commentResponse := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password")
if commentResponse.Code != http.StatusCreated {
t.Fatalf("create fixture comment: %d %s", commentResponse.Code, commentResponse.Body.String())
}
commentID := idFrom(commentResponse)
if response := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("deduplicate fixture comment: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodGet, "/api/creator/comments/"+commentID, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET fixture comment: %d %s", response.Code, response.Body.String())
}
profileBody := `{"real_name_status":"unknown","business_status":"normal","cooldown_seconds":60}`
if response := do(app, http.MethodPut, "/api/creator/accounts/fixture-account/profile", profileBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("update fixture profile: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/creator/accounts/fixture-account/big-account", `{"enabled":true}`, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("enable fixture big account: %d %s", response.Code, response.Body.String())
}
for _, accountID := range []string{"fixture-account", "fixture-small"} {
if _, err := creatorStore.UpdateAccountProfile(ctx, accountID, creator.AccountProfileUpdate{RealNameStatus: "unknown", BusinessStatus: "normal", BigAccount: accountID == "fixture-account", CooldownSeconds: 60}); err != nil {
t.Fatal(err)
}
}
if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-account", "fixture-platform"); err != nil {
t.Fatal(err)
}
if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-small", "fixture-small-platform"); err != nil {
t.Fatal(err)
}
ruleBody := `{"name":"fixture-rule","enabled":true,"source_type":"owned","topic":"title","include_keywords":["hello"],"ai_requirement":"lead"}`
ruleResponse := do(app, http.MethodPost, "/api/creator/rules", ruleBody, "operator", "unit-test-password")
if ruleResponse.Code != http.StatusCreated {
t.Fatalf("create fixture rule: %d %s", ruleResponse.Code, ruleResponse.Body.String())
}
ruleID := idFrom(ruleResponse)
if response := do(app, http.MethodGet, "/api/creator/rules/"+ruleID, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET fixture rule: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/creator/comments/"+commentID+"/analyze", `{"rule_id":"`+ruleID+`"}`, "operator", "unit-test-password"); response.Code != http.StatusServiceUnavailable {
t.Fatalf("analyze fixture comment without AI: %d %s", response.Code, response.Body.String())
}
competitor, err := creatorStore.CreateCompetitor(ctx, creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "competitor-key", Nickname: "Competitor", HomepageURL: "https://www.douyin.com/user/competitor-key"})
if err != nil {
t.Fatal(err)
}
competitorID := competitor.ID
if response := do(app, http.MethodGet, "/api/creator/competitors/"+competitorID, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET fixture competitor: %d %s", response.Code, response.Body.String())
}
for _, action := range []string{"pause", "resume", "sync"} {
if response := do(app, http.MethodPost, "/api/creator/competitors/"+competitorID+"/"+action, `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK && response.Code != http.StatusServiceUnavailable {
t.Fatalf("competitor %s: %d %s", action, response.Code, response.Body.String())
}
}
if _, err := syncCreatorCompetitorWithClaim(ctx, creatorStore, hubStore, competitorID, true); err == nil {
t.Fatal("competitor sync without anonymous browser unexpectedly succeeded")
}
if _, err := previewDouyinCompetitor(ctx, creatorStore, phaseAStore, hubStore, "fixture-account", creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "preview-key", Nickname: "Preview", HomepageURL: "https://www.douyin.com/user/preview-key"}); err == nil {
t.Fatal("competitor preview without browser unexpectedly succeeded")
}
// 自有账号监控:列表带作品聚合统计(fixture 已有 1 个 owned 作品)。
viewResponse := do(app, http.MethodGet, "/api/creator/accounts/monitor-views", "", "operator", "unit-test-password")
if viewResponse.Code != http.StatusOK {
t.Fatalf("GET monitor views: %d %s", viewResponse.Code, viewResponse.Body.String())
}
var monitorViews []struct {
ID string `json:"id"`
WorkCount int64 `json:"work_count"`
LatestPublishedAt *string `json:"latest_published_at"`
}
if err := json.Unmarshal(viewResponse.Body.Bytes(), &monitorViews); err != nil {
t.Fatal(err)
}
viewByAccount := map[string]bool{}
for _, view := range monitorViews {
viewByAccount[view.ID] = true
if view.ID == "fixture-account" && (view.WorkCount < 1 || view.LatestPublishedAt == nil) {
t.Fatalf("monitor view aggregates missing for fixture-account: %+v", view)
}
}
if !viewByAccount["fixture-account"] {
t.Fatal("monitor views missing fixture-account")
}
// 采集状态:无 checkpoint 时 pending;作品采集完成后 succeeded 且带窗口。
statusResponse := do(app, http.MethodGet, "/api/creator/accounts/fixture-small/collection-status", "", "operator", "unit-test-password")
if statusResponse.Code != http.StatusOK {
t.Fatalf("GET collection status: %d %s", statusResponse.Code, statusResponse.Body.String())
}
var collectionStatus struct {
Works struct {
Status string `json:"status"`
} `json:"works"`
}
if err := json.Unmarshal(statusResponse.Body.Bytes(), &collectionStatus); err != nil {
t.Fatal(err)
}
if collectionStatus.Works.Status != "pending" {
t.Fatalf("expected pending checkpoint for untouched account: %s", statusResponse.Body.String())
}
statusResponse = do(app, http.MethodGet, "/api/creator/accounts/fixture-account/collection-status", "", "operator", "unit-test-password")
if statusResponse.Code != http.StatusOK {
t.Fatalf("GET collection status: %d %s", statusResponse.Code, statusResponse.Body.String())
}
var fixtureStatus struct {
Works struct {
Status string `json:"status"`
NextWindowStart *string `json:"next_window_start"`
NextWindowEnd *string `json:"next_window_end"`
} `json:"works"`
}
if err := json.Unmarshal(statusResponse.Body.Bytes(), &fixtureStatus); err != nil {
t.Fatal(err)
}
// fixture 流程没有跑过调度器 checkpoint,状态可能仍为 pending;两种都合法。
if fixtureStatus.Works.Status != "pending" && fixtureStatus.Works.Status != "succeeded" {
t.Fatalf("unexpected works checkpoint status: %s", statusResponse.Body.String())
}
// 账号画像时序:初始为空数组。
metricsResponse := do(app, http.MethodGet, "/api/creator/accounts/fixture-account/metrics", "", "operator", "unit-test-password")
if metricsResponse.Code != http.StatusOK || strings.TrimSpace(metricsResponse.Body.String()) != "[]" {
t.Fatalf("GET account metrics: %d %s", metricsResponse.Code, metricsResponse.Body.String())
}
// 未登录账号强制同步:账号存在但未通过登录/环境校验,必须 409/503 且不吞错。
smallSync := do(app, http.MethodPost, "/api/creator/accounts/fixture-small/sync", ``, "operator", "unit-test-password")
if smallSync.Code != http.StatusConflict && smallSync.Code != http.StatusServiceUnavailable {
t.Fatalf("sync not-logged-in account must be blocked: %d %s", smallSync.Code, smallSync.Body.String())
}
if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-small", "fixture-small-platform"); err != nil {
t.Fatal(err)
}
loggedSync := do(app, http.MethodPost, "/api/creator/accounts/fixture-small/sync", ``, "operator", "unit-test-password")
if loggedSync.Code != http.StatusConflict && loggedSync.Code != http.StatusServiceUnavailable {
t.Fatalf("sync without runtime environment must be blocked: %d %s", loggedSync.Code, loggedSync.Body.String())
}
missingSync := do(app, http.MethodPost, "/api/creator/accounts/fixture-missing/sync", ``, "operator", "unit-test-password")
if missingSync.Code != http.StatusNotFound && missingSync.Code != http.StatusConflict && missingSync.Code != http.StatusServiceUnavailable {
t.Fatalf("sync missing account: %d %s", missingSync.Code, missingSync.Body.String())
}
if err := runCreatorScheduleOnce(ctx, creatorStore, phaseAStore, hubStore); err != nil && !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("creator schedule fixture: %v", err)
}
if err := runCreatorMetricScheduleOnce(ctx, creatorStore, phaseAStore, hubStore, time.Now().UTC()); err != nil && !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("creator metric schedule fixture: %v", err)
}
storedWork, err := creatorStore.GetWork(ctx, workID)
if err != nil {
t.Fatal(err)
}
settings, err := creatorStore.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if err := refreshCreatorMetricWork(ctx, creatorStore, phaseAStore, hubStore, storedWork, "fixture-account", settings, time.Now().UTC()); err == nil {
t.Fatal("metric refresh without browser unexpectedly succeeded")
}
if _, err := verifyCreatorAccount(ctx, creatorStore, phaseAStore, hubStore, "fixture-account"); err == nil {
t.Fatal("account verification without browser unexpectedly succeeded")
}
}
func TestPhaseAAccountRequestRejectsUnknownFields(t *testing.T) {
for name, body := range map[string]string{
"client id": `{"id":"account-a","name":"账号 A","platform":"douyin","platform_account_key":"a","tags":[],"cookies":"sid=value"}`,
"authorization kind": `{"name":"账号 A","platform":"douyin","platform_account_key":"a","tags":[],"cookies":"sid=value","authorization_kind":"owned"}`,
"credential reference": `{"name":"账号 A","platform":"douyin","platform_account_key":"a","tags":[],"cookies":"sid=value","credential_reference":{}}`,
} {
t.Run(name, func(t *testing.T) {
app := fiber.New()
app.Post("/", func(c fiber.Ctx) error {
var input accountRequest
if err := decodePhaseA(c, &input); err != nil {
return phaseAError(c, err)
}
return c.SendStatus(http.StatusNoContent)
})
response, err := app.Test(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("expected sensitive or unknown field to be rejected, got %d", response.StatusCode)
}
})
}
}
func TestPhaseAErrorRedactsInternalDetails(t *testing.T) {
app := fiber.New()
app.Get("/", func(c fiber.Ctx) error {
return phaseAError(c, errors.New("database exposed a secret value"))
})
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != http.StatusInternalServerError || strings.Contains(string(body), "secret") || !strings.Contains(string(body), "phase A operation failed") {
t.Fatalf("internal error was not redacted: status=%d body=%s", response.StatusCode, body)
}
}
func newHandlerWithStores(_ string, _ string, _ string, accountStore *account.Store, environmentStore *hub.Store) *fiber.App {
appInstance := fiber.New()
if environmentStore != nil {
RegisterEnvironments(appInstance, environmentStore)
}
return appInstance
}
func newHandlerWithCreator(_ string, _ string, _ string, accountStore *account.Store, environmentStore *hub.Store, _ account.CredentialBridge, creatorStore *creator.Store) *fiber.App {
appInstance := newHandlerWithStores("", "", "", accountStore, environmentStore)
if creatorStore != nil {
RegisterCreator(appInstance, creatorStore, accountStore, environmentStore)
}
return appInstance
}
func authenticate(username, password string) fiber.Handler {
wantUser, wantPassword := sha256.Sum256([]byte(username)), sha256.Sum256([]byte(password))
return func(c fiber.Ctx) error {
encoded, ok := strings.CutPrefix(c.Get(fiber.HeaderAuthorization), "Basic ")
decoded, err := base64.StdEncoding.DecodeString(encoded)
user, suppliedPassword, found := strings.Cut(string(decoded), ":")
gotUser, gotPassword := sha256.Sum256([]byte(user)), sha256.Sum256([]byte(suppliedPassword))
if !ok || err != nil || !found || subtle.ConstantTimeCompare(gotUser[:], wantUser[:]) != 1 || subtle.ConstantTimeCompare(gotPassword[:], wantPassword[:]) != 1 {
return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "authentication required"})
}
return c.Next()
}
}