284 lines
16 KiB
Go
284 lines
16 KiB
Go
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
|
|
resolveCalls *int
|
|
}
|
|
|
|
func (secrets fakeSecrets) Resolve(_ context.Context, _ SecretReference) ([]byte, error) {
|
|
if secrets.resolveCalls != nil {
|
|
(*secrets.resolveCalls)++
|
|
}
|
|
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{}
|
|
resolveCalls := 0
|
|
result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential), resolveCalls: &resolveCalls}, Store: store}).Sync(context.Background(), Request{
|
|
AccountID: "account-a", PlatformAccountKey: "sec-a",
|
|
})
|
|
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 resolveCalls != 0 {
|
|
t.Fatalf("active browser session unexpectedly resolved stored credentials: calls=%d", resolveCalls)
|
|
}
|
|
if len(browser.cookies) != 0 || len(browser.urls) != 2 ||
|
|
browser.urls[0] != identityEndpoint || !strings.Contains(browser.urls[1], "sec_user_id=sec-a") {
|
|
t.Fatalf("connector did not reuse 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 TestSyncPaginatesAndDeduplicatesAcrossPages(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,"max_cursor":20,"aweme_list":[{"aweme_id":"work-1","desc":"one","create_time":123,"statistics":{"digg_count":1,"comment_count":1,"share_count":1,"play_count":1}},{"aweme_id":"work-2","desc":"two","create_time":124,"statistics":{"digg_count":2,"comment_count":2,"share_count":2,"play_count":2}}]}`)},
|
|
{Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"max_cursor":40,"aweme_list":[{"aweme_id":"work-2","desc":"two-new","create_time":124,"statistics":{"digg_count":20,"comment_count":20,"share_count":20,"play_count":20}},{"aweme_id":"work-3","desc":"three","create_time":125,"statistics":{"digg_count":3,"comment_count":3,"share_count":3,"play_count":3}}]}`)},
|
|
}}
|
|
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.PaginationComplete || result.Evidence.Pages != 2 || result.Evidence.WorksSeen != 3 || result.Evidence.HasMore || len(store.works) != 3 {
|
|
t.Fatalf("unexpected paginated result: %#v works=%#v urls=%#v remaining=%d err=%v", result, store.works, browser.urls, len(browser.responses), err)
|
|
}
|
|
if len(browser.urls) != 3 || !strings.Contains(browser.urls[2], "max_cursor=20") {
|
|
t.Fatalf("connector did not follow the cursor: %#v", browser.urls)
|
|
}
|
|
}
|
|
|
|
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{err: errors.New("session unavailable")}
|
|
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 != StateNeedsConfirmation || result.ReasonCode != ReasonUnknown || len(browser.urls) != 1 || 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{err: errors.New("session unavailable")}
|
|
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 != StateNeedsConfirmation || result.ReasonCode != ReasonUnknown || len(browser.urls) != 1 || len(store.holds) != 1 {
|
|
t.Fatalf("browser session did not fail closed without credential injection: 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 TestParseCredentialAcceptsCookieHeaderAndBundle(t *testing.T) {
|
|
for name, raw := range map[string]string{
|
|
"header": "sessionid=secret; token=value",
|
|
"bundle": credential,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cookies, err := ParseCredential([]byte(raw))
|
|
if err != nil || len(cookies) != 2 && name == "header" || len(cookies) != 1 && name == "bundle" {
|
|
t.Fatalf("parse credential: cookies=%#v err=%v", cookies, 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 TestSyncIgnoresLegacyCredentialReference(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":[]}`)},
|
|
}}
|
|
result, err := (Connector{Browser: browser, 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) || result.State != StateSucceeded {
|
|
t.Fatalf("legacy credential reference affected browser-session sync: result=%#v err=%v", result, err)
|
|
}
|
|
}
|