mirror of
https://github.com/lukaszraczylo/mcp-readitall.git
synced 2026-10-15 01:07:36 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user