HH-868: add fail-closed Douyin read-only connector core (#31)
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
StateSucceeded = "succeeded"
|
||||
StatePolicyHold = "policy_hold"
|
||||
StateNeedsConfirmation = "needs_confirmation"
|
||||
|
||||
ReasonAuthInvalid = "douyin_auth_invalid"
|
||||
ReasonForbidden = "douyin_forbidden"
|
||||
ReasonRateLimited = "douyin_rate_limited"
|
||||
ReasonChallenge = "douyin_challenge"
|
||||
ReasonUnknown = "douyin_result_unknown"
|
||||
ReasonIdentityMatch = "douyin_identity_mismatch"
|
||||
ReasonSucceeded = "douyin_sync_succeeded"
|
||||
|
||||
identityEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/self/"
|
||||
worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/"
|
||||
)
|
||||
|
||||
var (
|
||||
keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
|
||||
credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`)
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("invalid douyin connector input")
|
||||
|
||||
type Challenge string
|
||||
|
||||
const (
|
||||
ChallengeNone Challenge = ""
|
||||
ChallengeCaptcha Challenge = "captcha"
|
||||
ChallengeDevice Challenge = "device"
|
||||
)
|
||||
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
Secure bool `json:"secure,omitempty"`
|
||||
HTTPOnly bool `json:"http_only,omitempty"`
|
||||
SameSite string `json:"same_site,omitempty"`
|
||||
Expires float64 `json:"expires,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Status int
|
||||
Body []byte
|
||||
Challenge Challenge
|
||||
}
|
||||
|
||||
// Browser is the deliberately narrow contract the restricted browser control
|
||||
// plane must implement. It does not permit arbitrary CDP commands.
|
||||
type Browser interface {
|
||||
SetCookies(context.Context, []Cookie) error
|
||||
Get(context.Context, string) (Response, error)
|
||||
}
|
||||
|
||||
type SecretReference struct {
|
||||
Provider string
|
||||
Key string
|
||||
}
|
||||
|
||||
type SecretResolver interface {
|
||||
Resolve(context.Context, SecretReference) ([]byte, error)
|
||||
}
|
||||
|
||||
type Work struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DiggCount int64 `json:"digg_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
ShareCount int64 `json:"share_count"`
|
||||
PlayCount int64 `json:"play_count"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
Phase string `json:"phase"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
IdentityVerified bool `json:"identity_verified"`
|
||||
WorksSeen int `json:"works_seen,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
State string `json:"state"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Evidence Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// Store owns both persistence/audit and fail-closed account/runtime handling.
|
||||
// Hold must pause the account and stop its existing bound runtime without retry.
|
||||
type Store interface {
|
||||
Complete(context.Context, string, []Work, Evidence) error
|
||||
Hold(context.Context, string, string, string, Evidence) error
|
||||
}
|
||||
|
||||
type Connector struct {
|
||||
Browser Browser
|
||||
Secrets SecretResolver
|
||||
Store Store
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
AccountID string
|
||||
PlatformAccountKey string
|
||||
Credential SecretReference
|
||||
}
|
||||
|
||||
func (connector Connector) Sync(ctx context.Context, request Request) (Result, error) {
|
||||
if connector.Browser == nil || connector.Secrets == nil || connector.Store == nil || !keyPattern.MatchString(request.AccountID) ||
|
||||
!keyPattern.MatchString(request.PlatformAccountKey) ||
|
||||
(request.Credential.Provider != "os_keyring" && request.Credential.Provider != "secret_manager") ||
|
||||
!credentialKeyPattern.MatchString(request.Credential.Key) {
|
||||
return Result{}, ErrInvalid
|
||||
}
|
||||
credential, err := connector.Secrets.Resolve(ctx, request.Credential)
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
||||
}
|
||||
cookies, err := parseCredential(credential)
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
||||
}
|
||||
if err := connector.Browser.SetCookies(ctx, cookies); err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "login"})
|
||||
}
|
||||
|
||||
identityResponse, err := connector.Browser.Get(ctx, identityEndpoint)
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "identity"})
|
||||
}
|
||||
if state, reason := classify(identityResponse); state != "" {
|
||||
return connector.stop(ctx, request.AccountID, state, reason, Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
||||
}
|
||||
identity, ok := parseIdentity(identityResponse.Body)
|
||||
if !ok {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown,
|
||||
Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
||||
}
|
||||
if request.PlatformAccountKey != identity.User.UID && request.PlatformAccountKey != identity.User.SecUID &&
|
||||
request.PlatformAccountKey != identity.User.UniqueID {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonIdentityMatch,
|
||||
Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
||||
}
|
||||
|
||||
query := url.Values{"sec_user_id": {identity.User.SecUID}, "count": {"20"}, "max_cursor": {"0"}}
|
||||
worksResponse, err := connector.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown,
|
||||
Evidence{Phase: "works", IdentityVerified: true})
|
||||
}
|
||||
if state, reason := classify(worksResponse); state != "" {
|
||||
return connector.stop(ctx, request.AccountID, state, reason,
|
||||
Evidence{Phase: "works", HTTPStatus: worksResponse.Status, IdentityVerified: true})
|
||||
}
|
||||
works, hasMore, ok := parseWorks(worksResponse.Body)
|
||||
evidence := Evidence{Phase: "works", HTTPStatus: worksResponse.Status, IdentityVerified: true, WorksSeen: len(works), HasMore: hasMore}
|
||||
if !ok {
|
||||
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
||||
}
|
||||
if err := connector.Store.Complete(ctx, request.AccountID, works, evidence); err != nil {
|
||||
result, holdErr := connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
||||
return result, errors.Join(err, holdErr)
|
||||
}
|
||||
return Result{State: StateSucceeded, ReasonCode: ReasonSucceeded, Evidence: evidence}, nil
|
||||
}
|
||||
|
||||
func (connector Connector) stop(ctx context.Context, accountID, state, reason string, evidence Evidence) (Result, error) {
|
||||
result := Result{State: state, ReasonCode: reason, Evidence: evidence}
|
||||
holdContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
return result, connector.Store.Hold(holdContext, accountID, state, reason, evidence)
|
||||
}
|
||||
|
||||
func classify(response Response) (string, string) {
|
||||
switch response.Challenge {
|
||||
case ChallengeCaptcha, ChallengeDevice:
|
||||
return StateNeedsConfirmation, ReasonChallenge
|
||||
case ChallengeNone:
|
||||
default:
|
||||
return StateNeedsConfirmation, ReasonUnknown
|
||||
}
|
||||
switch response.Status {
|
||||
case http.StatusUnauthorized:
|
||||
return StatePolicyHold, ReasonAuthInvalid
|
||||
case http.StatusForbidden:
|
||||
return StatePolicyHold, ReasonForbidden
|
||||
case http.StatusTooManyRequests:
|
||||
return StatePolicyHold, ReasonRateLimited
|
||||
case http.StatusOK:
|
||||
return "", ""
|
||||
default:
|
||||
return StateNeedsConfirmation, ReasonUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func parseCredential(raw []byte) ([]Cookie, error) {
|
||||
if len(raw) == 0 || len(raw) > 64<<10 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var bundle struct {
|
||||
Cookies []Cookie `json:"cookies"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&bundle); err != nil || len(bundle.Cookies) == 0 || len(bundle.Cookies) > 64 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var trailing json.RawMessage
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
for index := range bundle.Cookies {
|
||||
cookie := &bundle.Cookies[index]
|
||||
cookie.Domain = strings.ToLower(strings.TrimSpace(cookie.Domain))
|
||||
if cookie.Path == "" {
|
||||
cookie.Path = "/"
|
||||
}
|
||||
if cookie.Name == "" || len(cookie.Name) > 256 || len(cookie.Value) > 4096 || len(cookie.Domain) > 256 || len(cookie.Path) > 256 ||
|
||||
cookie.Expires < 0 || strings.ContainsAny(cookie.Name, ";\r\n\x00") || strings.ContainsAny(cookie.Value, ";\r\n\x00") ||
|
||||
(cookie.Domain != "douyin.com" && !strings.HasSuffix(cookie.Domain, ".douyin.com")) ||
|
||||
!strings.HasPrefix(cookie.Path, "/") || strings.ContainsAny(cookie.Path, ";\r\n\x00") ||
|
||||
(cookie.SameSite != "" && cookie.SameSite != "Lax" &&
|
||||
cookie.SameSite != "Strict" && cookie.SameSite != "None") {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
}
|
||||
return bundle.Cookies, nil
|
||||
}
|
||||
|
||||
type identityEnvelope struct {
|
||||
StatusCode *int `json:"status_code"`
|
||||
User *struct {
|
||||
UID string `json:"uid"`
|
||||
SecUID string `json:"sec_uid"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
func parseIdentity(body []byte) (identityEnvelope, bool) {
|
||||
var identity identityEnvelope
|
||||
if len(body) > 1<<20 || json.Unmarshal(body, &identity) != nil || identity.StatusCode == nil || *identity.StatusCode != 0 || identity.User == nil ||
|
||||
!keyPattern.MatchString(identity.User.UID) || !keyPattern.MatchString(identity.User.SecUID) ||
|
||||
(identity.User.UniqueID != "" && !keyPattern.MatchString(identity.User.UniqueID)) {
|
||||
return identityEnvelope{}, false
|
||||
}
|
||||
return identity, true
|
||||
}
|
||||
|
||||
type worksEnvelope struct {
|
||||
StatusCode *int `json:"status_code"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Works []struct {
|
||||
ID string `json:"aweme_id"`
|
||||
Description string `json:"desc"`
|
||||
CreatedAt *int64 `json:"create_time"`
|
||||
Statistics *struct {
|
||||
DiggCount *int64 `json:"digg_count"`
|
||||
CommentCount *int64 `json:"comment_count"`
|
||||
ShareCount *int64 `json:"share_count"`
|
||||
PlayCount *int64 `json:"play_count"`
|
||||
} `json:"statistics"`
|
||||
} `json:"aweme_list"`
|
||||
}
|
||||
|
||||
func parseWorks(body []byte) ([]Work, bool, bool) {
|
||||
var envelope worksEnvelope
|
||||
if len(body) > 4<<20 || json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 ||
|
||||
envelope.HasMore == nil || envelope.Works == nil || len(envelope.Works) > 20 {
|
||||
return nil, false, false
|
||||
}
|
||||
works := make([]Work, 0, len(envelope.Works))
|
||||
seen := make(map[string]bool, len(envelope.Works))
|
||||
for _, candidate := range envelope.Works {
|
||||
if !keyPattern.MatchString(candidate.ID) || seen[candidate.ID] || candidate.CreatedAt == nil || *candidate.CreatedAt <= 0 ||
|
||||
candidate.Statistics == nil || candidate.Statistics.DiggCount == nil || candidate.Statistics.CommentCount == nil ||
|
||||
candidate.Statistics.ShareCount == nil || candidate.Statistics.PlayCount == nil ||
|
||||
utf8.RuneCountInString(candidate.Description) > 4096 || *candidate.Statistics.DiggCount < 0 ||
|
||||
*candidate.Statistics.CommentCount < 0 || *candidate.Statistics.ShareCount < 0 || *candidate.Statistics.PlayCount < 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
works = append(works, Work{ID: candidate.ID, Description: candidate.Description, CreatedAt: *candidate.CreatedAt,
|
||||
DiggCount: *candidate.Statistics.DiggCount, CommentCount: *candidate.Statistics.CommentCount,
|
||||
ShareCount: *candidate.Statistics.ShareCount, PlayCount: *candidate.Statistics.PlayCount})
|
||||
}
|
||||
return works, *envelope.HasMore, true
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const credential = `{"cookies":[{"name":"sessionid","value":"private-session","domain":".douyin.com","path":"/","secure":true,"http_only":true,"same_site":"Lax"}]}`
|
||||
|
||||
var secretReference = SecretReference{Provider: "os_keyring", Key: "creatorhub/account-a"}
|
||||
|
||||
type fakeSecrets struct {
|
||||
value []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (secrets fakeSecrets) Resolve(_ context.Context, _ SecretReference) ([]byte, error) {
|
||||
return secrets.value, secrets.err
|
||||
}
|
||||
|
||||
type fakeBrowser struct {
|
||||
responses []Response
|
||||
err error
|
||||
cookies []Cookie
|
||||
urls []string
|
||||
}
|
||||
|
||||
func (browser *fakeBrowser) SetCookies(_ context.Context, cookies []Cookie) error {
|
||||
browser.cookies = cookies
|
||||
return browser.err
|
||||
}
|
||||
|
||||
func (browser *fakeBrowser) Get(_ context.Context, target string) (Response, error) {
|
||||
browser.urls = append(browser.urls, target)
|
||||
if browser.err != nil {
|
||||
return Response{}, browser.err
|
||||
}
|
||||
response := browser.responses[0]
|
||||
browser.responses = browser.responses[1:]
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
works []Work
|
||||
holds []Result
|
||||
completeCalls int
|
||||
holdContextErr error
|
||||
completeErr error
|
||||
holdErr error
|
||||
}
|
||||
|
||||
func (store *fakeStore) Complete(_ context.Context, _ string, works []Work, _ Evidence) error {
|
||||
store.completeCalls++
|
||||
store.works = works
|
||||
return store.completeErr
|
||||
}
|
||||
|
||||
func (store *fakeStore) Hold(ctx context.Context, _ string, state, reason string, evidence Evidence) error {
|
||||
store.holdContextErr = ctx.Err()
|
||||
store.holds = append(store.holds, Result{State: state, ReasonCode: reason, Evidence: evidence})
|
||||
return store.holdErr
|
||||
}
|
||||
|
||||
func identityBody(uid, secUID, uniqueID string) []byte {
|
||||
body, _ := json.Marshal(map[string]any{"status_code": 0, "user": map[string]string{
|
||||
"uid": uid, "sec_uid": secUID, "unique_id": uniqueID,
|
||||
}})
|
||||
return body
|
||||
}
|
||||
|
||||
func TestSyncLogsInVerifiesIdentityAndReadsOwnWorks(t *testing.T) {
|
||||
browser := &fakeBrowser{responses: []Response{
|
||||
{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")},
|
||||
{Status: 200, Body: []byte(`{"status_code":0,"has_more":true,"aweme_list":[{"aweme_id":"work-1","desc":"hello","create_time":123,"statistics":{"digg_count":4,"comment_count":3,"share_count":2,"play_count":1}}]}`)},
|
||||
}}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StateSucceeded || !result.Evidence.IdentityVerified || result.Evidence.WorksSeen != 1 || !result.Evidence.HasMore {
|
||||
t.Fatalf("unexpected result: %#v err=%v", result, err)
|
||||
}
|
||||
if len(browser.cookies) != 1 || browser.cookies[0].Value != "private-session" || len(browser.urls) != 2 ||
|
||||
browser.urls[0] != identityEndpoint || !strings.Contains(browser.urls[1], "sec_user_id=sec-a") {
|
||||
t.Fatalf("connector did not use the bound browser session: cookies=%#v urls=%#v", browser.cookies, browser.urls)
|
||||
}
|
||||
if len(store.works) != 1 || store.works[0].ID != "work-1" || store.works[0].PlayCount != 1 || len(store.holds) != 0 {
|
||||
t.Fatalf("unexpected persisted works or hold: works=%#v holds=%#v", store.works, store.holds)
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
if strings.Contains(string(encoded), "private-session") {
|
||||
t.Fatalf("audit result leaked credential: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncMapsRiskSignalsAndNeverRetries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response Response
|
||||
state string
|
||||
reason string
|
||||
}{
|
||||
{name: "authentication invalid", response: Response{Status: 401}, state: StatePolicyHold, reason: ReasonAuthInvalid},
|
||||
{name: "forbidden", response: Response{Status: 403}, state: StatePolicyHold, reason: ReasonForbidden},
|
||||
{name: "rate limited", response: Response{Status: 429}, state: StatePolicyHold, reason: ReasonRateLimited},
|
||||
{name: "captcha", response: Response{Status: 200, Challenge: ChallengeCaptcha}, state: StateNeedsConfirmation, reason: ReasonChallenge},
|
||||
{name: "device challenge", response: Response{Status: 200, Challenge: ChallengeDevice}, state: StateNeedsConfirmation, reason: ReasonChallenge},
|
||||
{name: "unknown status", response: Response{Status: 502}, state: StateNeedsConfirmation, reason: ReasonUnknown},
|
||||
{name: "unknown challenge", response: Response{Status: 200, Challenge: "future"}, state: StateNeedsConfirmation, reason: ReasonUnknown},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
browser := &fakeBrowser{responses: []Response{test.response}}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != test.state || result.ReasonCode != test.reason || len(store.holds) != 1 {
|
||||
t.Fatalf("unexpected stop: result=%#v holds=%#v err=%v", result, store.holds, err)
|
||||
}
|
||||
if len(browser.urls) != 1 {
|
||||
t.Fatalf("risk response was retried: %#v", browser.urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFailsClosedOnIdentityAndUnknownResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
browser *fakeBrowser
|
||||
key string
|
||||
reason string
|
||||
phase string
|
||||
}{
|
||||
{name: "identity mismatch", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}}}, key: "another", reason: ReasonIdentityMatch, phase: "identity"},
|
||||
{name: "malformed identity", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: []byte(`{"status_code":0}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "identity"},
|
||||
{name: "identity status missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: []byte(`{"user":{"uid":"uid-a","sec_uid":"sec-a","unique_id":"handle-a"}}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "identity"},
|
||||
{name: "browser failure", browser: &fakeBrowser{err: errors.New("transport details must stay internal")}, key: "sec-a", reason: ReasonUnknown, phase: "login"},
|
||||
{name: "malformed works", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"aweme_list":[{"aweme_id":""}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "works list missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "works has_more missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"aweme_list":[]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work create_time missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work create_time null", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":null,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistics missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistics null", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1,"statistics":null}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistic missing", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
{name: "work statistic null", browser: &fakeBrowser{responses: []Response{{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"work-1","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":null}}]}`)}}}, key: "sec-a", reason: ReasonUnknown, phase: "works"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: test.browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: test.key, Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StateNeedsConfirmation || result.ReasonCode != test.reason || result.Evidence.Phase != test.phase || len(store.holds) != 1 || store.completeCalls != 0 {
|
||||
t.Fatalf("unexpected fail-closed result: %#v holds=%#v err=%v", result, store.holds, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRejectsInvalidCredentialWithoutLeakingIt(t *testing.T) {
|
||||
browser := &fakeBrowser{}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(`{"cookies":[{"name":"sessionid","value":"secret","domain":"evil.example"}]}`)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StatePolicyHold || result.ReasonCode != ReasonAuthInvalid || len(browser.urls) != 0 || len(browser.cookies) != 0 || len(store.holds) != 1 {
|
||||
t.Fatalf("unexpected invalid credential result: %#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
if strings.Contains(string(encoded), "secret") || strings.Contains(string(encoded), "evil") {
|
||||
t.Fatalf("stop evidence leaked credential: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncStopsWhenSecretReferenceCannotResolve(t *testing.T) {
|
||||
browser := &fakeBrowser{}
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{err: errors.New("secret unavailable")}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StatePolicyHold || result.ReasonCode != ReasonAuthInvalid || len(browser.urls) != 0 || len(store.holds) != 1 {
|
||||
t.Fatalf("unavailable secret did not fail closed: result=%#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFailsClosedWhenPersistenceIsUnknown(t *testing.T) {
|
||||
browser := &fakeBrowser{responses: []Response{
|
||||
{Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")},
|
||||
{Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[]}`)},
|
||||
}}
|
||||
store := &fakeStore{completeErr: errors.New("database result unknown")}
|
||||
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential)}, Store: store}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err == nil || result.State != StateNeedsConfirmation || result.ReasonCode != ReasonUnknown || len(store.holds) != 1 {
|
||||
t.Fatalf("persistence uncertainty did not stop: result=%#v holds=%#v err=%v", result, store.holds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAndWorkValidation(t *testing.T) {
|
||||
invalidCredentials := []string{
|
||||
``, `{}`, `{"cookies":[]}`, `{"cookies":[{"name":"a","value":"b","domain":".douyin.com","extra":true}]}`,
|
||||
`{"cookies":[{"name":"a;bad","value":"b","domain":".douyin.com"}]}`,
|
||||
credential + `true`, credential + `[]`, credential + `null`, credential + `garbage`,
|
||||
}
|
||||
for _, input := range invalidCredentials {
|
||||
if _, err := parseCredential([]byte(input)); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("accepted invalid credential bundle: %q", input)
|
||||
}
|
||||
}
|
||||
if _, _, ok := parseWorks([]byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"same","desc":"a","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}},{"aweme_id":"same","desc":"b","create_time":1,"statistics":{"digg_count":0,"comment_count":0,"share_count":0,"play_count":0}}]}`)); ok {
|
||||
t.Fatal("accepted duplicate work ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncHoldsWithCancelledRequestContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
store := &fakeStore{}
|
||||
result, err := (Connector{Browser: &fakeBrowser{}, Secrets: fakeSecrets{err: context.Canceled}, Store: store}).Sync(ctx, Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference,
|
||||
})
|
||||
if err != nil || result.State != StatePolicyHold || len(store.holds) != 1 || store.holdContextErr != nil {
|
||||
t.Fatalf("cancelled request did not durably hold: result=%#v holds=%#v context_err=%v err=%v", result, store.holds, store.holdContextErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRejectsNonSecretCredentialReference(t *testing.T) {
|
||||
_, err := (Connector{Browser: &fakeBrowser{}, Secrets: fakeSecrets{}, Store: &fakeStore{}}).Sync(context.Background(), Request{
|
||||
AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: SecretReference{Provider: "plain_text", Key: "raw-secret"},
|
||||
})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("accepted non-secret credential reference: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user