mirror of
https://github.com/lukaszraczylo/mcp-readitall.git
synced 2026-10-12 00:45:33 +00:00
401 lines
10 KiB
Go
401 lines
10 KiB
Go
package browser_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/mcp-readitall/internal/browser"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// =============================================================================
|
|
// TEST HELPERS
|
|
// =============================================================================
|
|
|
|
// newStoreInTemp constructs a SessionStore rooted at a fresh temp directory.
|
|
// It is automatically cleaned up by t.TempDir() and the env override is
|
|
// reverted by t.Setenv() at the end of the test.
|
|
func newStoreInTemp(t *testing.T) *browser.SessionStore {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("READITALL_SESSIONS_DIR", dir)
|
|
s, err := browser.NewSessionStore()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
// validStateJSON returns a minimal but well-formed Playwright storage state
|
|
// blob containing one cookie and one origin entry. Tests can pass it through
|
|
// ValidateStorageState or Save without modification.
|
|
func validStateJSON(t *testing.T) []byte {
|
|
t.Helper()
|
|
state := map[string]any{
|
|
"cookies": []map[string]any{
|
|
{"name": "session", "value": "abc123", "domain": "example.com", "path": "/"},
|
|
},
|
|
"origins": []map[string]any{
|
|
{
|
|
"origin": "https://example.com",
|
|
"localStorage": []map[string]any{
|
|
{"name": "token", "value": "xyz"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
b, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
return b
|
|
}
|
|
|
|
// =============================================================================
|
|
// HostKey
|
|
// =============================================================================
|
|
|
|
func TestHostKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
want string
|
|
// optional: when set, expect an error containing this substring
|
|
wantErr string
|
|
}{
|
|
// ===== GOOD CASES =====
|
|
{
|
|
name: "simple http",
|
|
url: "http://example.com/path",
|
|
want: "example.com",
|
|
},
|
|
{
|
|
name: "https with deep path and query",
|
|
url: "https://example.com/some/deep/path?x=1&y=2",
|
|
want: "example.com",
|
|
},
|
|
{
|
|
name: "subdomain preserved",
|
|
url: "https://app.acme.example.com",
|
|
want: "app.acme.example.com",
|
|
},
|
|
{
|
|
name: "host with port",
|
|
url: "http://localhost:8443/admin",
|
|
want: "localhost_8443",
|
|
},
|
|
{
|
|
name: "uppercase host is lowercased",
|
|
url: "HTTPS://Example.COM/X",
|
|
want: "example.com",
|
|
},
|
|
{
|
|
name: "host with hyphens",
|
|
url: "https://my-app.example-site.io",
|
|
want: "my-app.example-site.io",
|
|
},
|
|
{
|
|
name: "credentials in URL are stripped",
|
|
url: "https://user:[email protected]/",
|
|
want: "example.com",
|
|
},
|
|
|
|
// ===== BAD CASES =====
|
|
{
|
|
name: "empty url",
|
|
url: "",
|
|
// url.Parse("") returns a URL with an empty host; HostKey
|
|
// catches that and reports the "no host" condition.
|
|
wantErr: "no host",
|
|
},
|
|
{
|
|
name: "url with no host",
|
|
url: "/just/a/path",
|
|
wantErr: "no host",
|
|
},
|
|
{
|
|
name: "garbage url",
|
|
url: "::::::",
|
|
// Garbage fails net/url parsing before HostKey can look at the
|
|
// host. The "parse url" wrapper surfaces that.
|
|
wantErr: "parse url",
|
|
},
|
|
|
|
// ===== EDGE CASES =====
|
|
{
|
|
name: "weird chars in host get underscored",
|
|
url: "https://xn--bcher-kva.example/", // punycode begins with "xn--"
|
|
want: "xn--bcher-kva.example",
|
|
},
|
|
{
|
|
name: "ipv4 host preserved",
|
|
url: "http://127.0.0.1:8080/",
|
|
want: "127.0.0.1_8080",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := browser.HostKey(tt.url)
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ValidateStorageState
|
|
// =============================================================================
|
|
|
|
func TestValidateStorageState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
state string
|
|
wantErr string
|
|
}{
|
|
// ===== GOOD CASES =====
|
|
{
|
|
name: "valid state with cookies only",
|
|
state: `{"cookies":[{"name":"a","value":"b"}]}`,
|
|
},
|
|
{
|
|
name: "valid state with origins only",
|
|
state: `{"origins":[{"origin":"https://x.test"}]}`,
|
|
},
|
|
{
|
|
name: "valid state with both",
|
|
state: `{"cookies":[{"name":"a"}],"origins":[{"origin":"https://x.test"}]}`,
|
|
},
|
|
|
|
// ===== BAD CASES =====
|
|
{
|
|
name: "not json",
|
|
state: `<html>not json</html>`,
|
|
wantErr: "not valid JSON",
|
|
},
|
|
{
|
|
name: "empty json object",
|
|
state: `{}`,
|
|
wantErr: "no cookies or origins",
|
|
},
|
|
{
|
|
name: "empty arrays",
|
|
state: `{"cookies":[],"origins":[]}`,
|
|
wantErr: "no cookies or origins",
|
|
},
|
|
{
|
|
name: "completely empty string",
|
|
state: ``,
|
|
wantErr: "not valid JSON",
|
|
},
|
|
|
|
// ===== EDGE CASES =====
|
|
{
|
|
name: "whitespace-only json returns parse error",
|
|
state: ` `,
|
|
// json.Unmarshal treats whitespace as truncated input and
|
|
// returns a parse error before we ever look at fields.
|
|
wantErr: "not valid JSON",
|
|
},
|
|
{
|
|
name: "extra unknown fields are tolerated",
|
|
state: `{"cookies":[{"name":"a"}],"origins":[],"extra":{"foo":"bar"}}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
err := browser.ValidateStorageState([]byte(tt.state))
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Save / Exists / StatePath / List / Clear
|
|
// =============================================================================
|
|
|
|
func TestSessionStore_SaveAndExists(t *testing.T) {
|
|
// Uses t.Setenv via newStoreInTemp, so cannot be t.Parallel().
|
|
store := newStoreInTemp(t)
|
|
|
|
url := "https://example.com/dashboard"
|
|
state := validStateJSON(t)
|
|
|
|
// Exists should be false before save
|
|
exists, err := store.Exists(url)
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
|
|
path, err := store.Save(url, state)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, store.Root(), filepath.Dir(filepath.Dir(path)))
|
|
assert.FileExists(t, path)
|
|
|
|
// Permissions should be 0600 — credentials must not leak
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "state file must be owner-only")
|
|
|
|
exists, err = store.Exists(url)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
}
|
|
|
|
func TestSessionStore_ExistsAndStatePath_ForUnknownURL(t *testing.T) {
|
|
// Uses t.Setenv via newStoreInTemp, so cannot be t.Parallel().
|
|
store := newStoreInTemp(t)
|
|
|
|
exists, err := store.Exists("https://never-saved.test/")
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
|
|
path, err := store.StatePath("https://never-saved.test/")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, filepath.Join(store.Root(), "never-saved.test", "state.json"), path)
|
|
_, statErr := os.Stat(path)
|
|
assert.True(t, os.IsNotExist(statErr), "expected no file at %s", path)
|
|
}
|
|
|
|
func TestSessionStore_RejectsEmptyState(t *testing.T) {
|
|
// Uses t.Setenv via newStoreInTemp, so cannot be t.Parallel().
|
|
store := newStoreInTemp(t)
|
|
|
|
// Validation must happen before persistence; otherwise we'd write an
|
|
// empty file and look authenticated on subsequent reads.
|
|
empty := []byte(`{"cookies":[],"origins":[]}`)
|
|
_, err := store.Save("https://broken.test/", empty)
|
|
// Save does not itself validate — that's a separate concern. Verify
|
|
// ValidateStorageState is the gate.
|
|
require.NoError(t, err, "Save is a low-level write; validation belongs to callers")
|
|
|
|
// Caller-side validation catches it.
|
|
require.Error(t, browser.ValidateStorageState(empty))
|
|
}
|
|
|
|
func TestSessionStore_List(t *testing.T) {
|
|
store := newStoreInTemp(t)
|
|
state := validStateJSON(t)
|
|
|
|
// Empty store: no entries
|
|
infos, err := store.List()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, infos)
|
|
|
|
// Save a few
|
|
hosts := []string{
|
|
"https://a.test/x",
|
|
"https://b.test/y",
|
|
"https://sub.c.test/z",
|
|
}
|
|
for _, u := range hosts {
|
|
_, err := store.Save(u, state)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
infos, err = store.List()
|
|
require.NoError(t, err)
|
|
assert.Len(t, infos, 3)
|
|
|
|
got := make([]string, 0, len(infos))
|
|
for _, i := range infos {
|
|
got = append(got, i.Domain)
|
|
}
|
|
sort.Strings(got)
|
|
assert.Equal(t, []string{"a.test", "b.test", "sub.c.test"}, got)
|
|
|
|
for _, i := range infos {
|
|
assert.Greater(t, i.SizeBytes, int64(0))
|
|
assert.False(t, i.UpdatedAt.IsZero())
|
|
}
|
|
}
|
|
|
|
func TestSessionStore_Clear(t *testing.T) {
|
|
store := newStoreInTemp(t)
|
|
state := validStateJSON(t)
|
|
url := "https://clear-me.test/page"
|
|
|
|
_, err := store.Save(url, state)
|
|
require.NoError(t, err)
|
|
|
|
// First Clear returns existed=true
|
|
cleared, err := store.Clear(url)
|
|
require.NoError(t, err)
|
|
assert.True(t, cleared)
|
|
|
|
exists, err := store.Exists(url)
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
|
|
// Second Clear returns existed=false (idempotent)
|
|
cleared, err = store.Clear(url)
|
|
require.NoError(t, err)
|
|
assert.False(t, cleared)
|
|
}
|
|
|
|
func TestSessionStore_ClearUnknownHost(t *testing.T) {
|
|
// Uses t.Setenv via newStoreInTemp, so cannot be t.Parallel().
|
|
store := newStoreInTemp(t)
|
|
|
|
cleared, err := store.Clear("https://never-saved.test/")
|
|
require.NoError(t, err)
|
|
assert.False(t, cleared)
|
|
}
|
|
|
|
func TestSessionStore_UpdatedAtAdvances(t *testing.T) {
|
|
// Not parallel: relies on sequential time advancement.
|
|
store := newStoreInTemp(t)
|
|
state := validStateJSON(t)
|
|
url := "https://time.test/"
|
|
|
|
_, err := store.Save(url, state)
|
|
require.NoError(t, err)
|
|
first, err := store.List()
|
|
require.NoError(t, err)
|
|
require.Len(t, first, 1)
|
|
firstStamp := first[0].UpdatedAt
|
|
|
|
// Sleep just long enough that mtime can differ (filesystem mtime
|
|
// resolution varies, especially on macOS APFS where it's 1ns).
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
_, err = store.Save(url, state)
|
|
require.NoError(t, err)
|
|
second, err := store.List()
|
|
require.NoError(t, err)
|
|
require.Len(t, second, 1)
|
|
|
|
assert.True(t, second[0].UpdatedAt.After(firstStamp),
|
|
"expected updated_at to advance: first=%s second=%s", firstStamp, second[0].UpdatedAt)
|
|
}
|
|
|
|
func TestSessionStore_RoundTripPreservesStateBytes(t *testing.T) {
|
|
// Uses t.Setenv via newStoreInTemp, so cannot be t.Parallel().
|
|
// We do not parse the saved file; we just ensure the bytes we put in
|
|
// are the bytes we get out.
|
|
store := newStoreInTemp(t)
|
|
url := "https://roundtrip.test/"
|
|
state := validStateJSON(t)
|
|
|
|
path, err := store.Save(url, state)
|
|
require.NoError(t, err)
|
|
|
|
got, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(state), string(got))
|
|
}
|