web/public_link_events_test.go
Rama master · réplica de lectura del repositorio original.
package web_test
import (
"archive/zip"
"bytes"
"crypto/rand"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/model/publiclink"
"github.com/cozy/cozy-stack/model/stack"
"github.com/cozy/cozy-stack/model/vfs"
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
"github.com/cozy/cozy-stack/pkg/couchdb/mango"
"github.com/cozy/cozy-stack/tests/testutils"
"github.com/cozy/cozy-stack/web"
"github.com/cozy/cozy-stack/web/apps"
weberrors "github.com/cozy/cozy-stack/web/errors"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
)
func TestPublicLinkEvents(t *testing.T) {
if testing.Short() {
t.Skip("requires CouchDB and Redis")
}
config.UseTestFile(t)
config.GetConfig().Assets = "../assets"
testutils.NeedCouchdb(t)
setup := testutils.NewSetup(t, t.Name())
inst := setup.GetTestInstance()
inst.FeatureFlags = map[string]interface{}{publiclink.FlagEnabled: true}
require.NoError(t, instance.Update(inst))
_, ownerToken := setup.GetTestClient(consts.Files)
require.NoError(t, couchdb.CreateDB(inst, consts.PublicLinkEvents))
done := make(chan struct{}, 100)
api := echo.New()
api.HTTPErrorHandler = weberrors.ErrorHandler
api.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { defer func() { done <- struct{}{} }(); return next(c) }
})
router, err := web.CreateSubdomainProxy(api, &stack.Services{}, apps.Serve)
require.NoError(t, err)
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
request := func(method, path, token string, body []byte, headers map[string]string, status int) []byte {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, bytes.NewReader(body))
require.NoError(t, err)
req.Host = inst.Domain
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := ts.Client().Do(req)
require.NoError(t, err)
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
require.NoError(t, err)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("handler did not finish")
}
require.Equal(t, status, res.StatusCode, string(b))
return b
}
makeFile := func(path string) string {
t.Helper()
f, err := vfs.Create(inst.VFS(), path)
require.NoError(t, err)
_, err = io.WriteString(f, "public link content")
require.NoError(t, err)
require.NoError(t, f.Close())
doc, err := inst.VFS().FileByPath(path)
require.NoError(t, err)
return doc.ID()
}
rootID := makeFile("/root.txt")
dir, err := vfs.Mkdir(inst.VFS(), "/shared", nil)
require.NoError(t, err)
childID := makeFile("/shared/child.txt")
events := func() []publiclink.Event {
t.Helper()
var ev []publiclink.Event
require.NoError(t, couchdb.FindDocsUnoptimized(inst, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Exists("link_id")}, &ev))
return ev
}
link := func(id string) (string, string) {
t.Helper()
b := request("POST", "/permissions?codes=link", ownerToken, []byte(`{"data":{"type":"io.cozy.permissions","attributes":{"permissions":{"files":{"type":"io.cozy.files","verbs":["GET"],"values":["`+id+`"]}}}}}`), map[string]string{"X-Forwarded-For": "2001:db8::123, 192.0.2.1"}, 200)
var payload struct {
Data struct {
ID string
Attributes struct{ Codes map[string]string }
}
}
require.NoError(t, json.Unmarshal(b, &payload))
return payload.Data.ID, payload.Data.Attributes.Codes["link"]
}
linkID, token := link(rootID)
dirLinkID, dirToken := link(dir.ID())
require.Len(t, events(), 2)
for _, ev := range events() {
require.Equal(t, publiclink.Created, ev.Action)
require.Equal(t, "2001:db8::123", ev.IP)
require.Equal(t, inst.Domain, ev.Identity)
require.WithinDuration(t, time.Now(), ev.OccurredAt, 5*time.Second)
}
// Successful updates, not failed updates or revocation, are link actions.
request("PATCH", "/permissions/"+linkID, ownerToken, []byte(`{"data":{"type":"io.cozy.permissions","attributes":{"expires_at":"2099-01-01T00:00:00Z"}}}`), nil, 200)
request("PATCH", "/permissions/"+linkID, ownerToken, []byte(`{"data":{"type":"io.cozy.permissions","attributes":{"expires_at":"invalid"}}}`), nil, 422)
require.Len(t, events(), 3)
for _, tc := range []struct {
method, path, tok string
headers map[string]string
status int
}{
{"GET", "/files/" + rootID, token, nil, 200},
{"GET", "/files/download/" + rootID, token, nil, 200},
{"HEAD", "/files/download/" + rootID + "?Dl=1", token, nil, 200},
{"GET", "/files/download/" + rootID + "?Dl=1", token, map[string]string{"Range": "bytes=0-2"}, 206},
{"GET", "/files/download/" + childID + "?Dl=1", dirToken, nil, 200},
{"GET", "/files/download/" + rootID + "?Dl=1", "", nil, 401},
} {
request(tc.method, tc.path, tc.tok, nil, tc.headers, tc.status)
}
require.Len(t, events(), 3)
for n := 0; n < 2; n++ {
b := request("GET", "/files/download/"+rootID+"?Dl=1", token, nil, map[string]string{"X-Forwarded-For": "192.0.2.55"}, 200)
require.Equal(t, "public link content", string(b))
}
require.Len(t, events(), 5, "no deduplication")
related := func(b []byte) string {
t.Helper()
var p struct{ Links struct{ Related string } }
require.NoError(t, json.Unmarshal(b, &p))
require.NotEmpty(t, p.Links.Related)
return p.Links.Related
}
fileURL := related(request("POST", "/files/downloads?Id="+rootID, token, nil, nil, 200))
require.Len(t, events(), 6, "preparing a secret records the intent even without a GET")
require.Equal(t, "public link content", string(request("GET", fileURL+"?Dl=1", "", nil, nil, 200)))
require.Equal(t, "public link content", string(request("GET", fileURL+"?Dl=1&sharecode="+token, "", nil, nil, 200)))
require.Len(t, events(), 6, "reusing a secret with a sharecode must not count again")
legacy, err := vfs.GetStore().AddFile(inst, "/root.txt")
require.NoError(t, err)
request("GET", "/files/downloads/"+legacy+"/file?Dl=1", token, nil, nil, 200)
require.Len(t, events(), 6, "old secrets have no attributable link")
archiveBody := []byte(`{"data":{"type":"io.cozy.files.archives","attributes":{"name":"shared","ids":["` + dir.ID() + `"]}}}`)
archiveURL := related(request("POST", "/files/archive", dirToken, archiveBody, nil, 200))
require.Len(t, events(), 7, "archive preparation counts before consumption")
b := request("GET", archiveURL, "", nil, nil, 200)
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
require.NoError(t, err)
require.Len(t, zr.File, 2)
require.Len(t, events(), 7)
request("POST", "/files/archive", dirToken, archiveBody, map[string]string{"Accept": "application/zip"}, 200)
require.Len(t, events(), 8)
request("GET", archiveURL, "", nil, map[string]string{"Range": "bytes=0-2"}, 200)
selection := []byte(`{"data":{"type":"io.cozy.files.archives","attributes":{"name":"selection","ids":["` + childID + `"]}}}`)
selectionURL := related(request("POST", "/files/archive", dirToken, selection, nil, 200))
request("GET", selectionURL, "", nil, nil, 200)
request("GET", "/files/archive/invalid/bad.zip", "", nil, nil, 400)
require.Len(t, events(), 8)
request("DELETE", "/permissions/"+linkID, ownerToken, nil, nil, 204)
require.Len(t, events(), 8, "revocation is outside the event scope")
counts := map[string]int{}
for _, ev := range events() {
counts[ev.LinkID]++
if ev.Action == publiclink.Downloaded {
require.Empty(t, ev.Identity)
}
}
require.Equal(t, map[string]int{linkID: 5, dirLinkID: 3}, counts)
// Existing capabilities keep their old access semantics after revocation.
// Reusing them does not record another intent.
request("GET", fileURL+"?Dl=1", "", nil, nil, 200)
require.Len(t, events(), 8)
t.Run("EmptyDirectory", func(t *testing.T) {
empty, err := vfs.Mkdir(inst.VFS(), "/empty", nil)
require.NoError(t, err)
id, code := link(empty.ID())
body := []byte(`{"data":{"type":"io.cozy.files.archives","attributes":{"name":"empty","ids":["` + empty.ID() + `"]}}}`)
path := related(request("POST", "/files/archive", code, body, nil, 200))
b := request("GET", path, "", nil, nil, 200)
reader, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
require.NoError(t, err)
require.Len(t, reader.File, 1)
var actions []publiclink.Action
for _, event := range events() {
if event.LinkID == id {
actions = append(actions, event.Action)
}
}
require.ElementsMatch(t, []publiclink.Action{publiclink.Created, publiclink.Downloaded}, actions)
})
t.Run("PreparationScopeAndFlag", func(t *testing.T) {
id, code := link(rootID)
downloads := func() []publiclink.Event {
var result []publiclink.Event
for _, event := range events() {
if event.LinkID == id && event.Action == publiclink.Downloaded {
result = append(result, event)
}
}
return result
}
before := len(events())
request("POST", "/files/downloads?Id="+rootID, "", nil, nil, 401)
request("POST", "/files/downloads?Id=missing", code, nil, nil, 404)
request("POST", "/files/downloads?VersionId="+rootID+"/old", code, nil, nil, 200)
request("POST", "/files/downloads?Id="+childID, dirToken, nil, nil, 200)
require.Len(t, events(), before, "failed, version and child preparations are excluded")
inst.FeatureFlags[publiclink.FlagEnabled] = false
require.NoError(t, instance.Update(inst))
path := related(request("POST", "/files/downloads?Id="+rootID, code, nil, nil, 200))
require.Empty(t, downloads())
inst.FeatureFlags[publiclink.FlagEnabled] = true
require.NoError(t, instance.Update(inst))
require.Equal(t, "public link content", string(request("GET", path+"?Dl=1", code, nil, nil, 200)))
require.Empty(t, downloads(), "enabling later must not catch up an unrecorded preparation")
path = related(request("POST", "/files/downloads?Id="+rootID, code, nil, map[string]string{"X-Forwarded-For": "192.0.2.77"}, 200))
require.Len(t, downloads(), 1)
require.Equal(t, "192.0.2.77", downloads()[0].IP)
require.Empty(t, downloads()[0].Identity)
inst.FeatureFlags[publiclink.FlagEnabled] = false
require.NoError(t, instance.Update(inst))
request("GET", path+"?Dl=1", code, nil, map[string]string{"X-Forwarded-For": "192.0.2.88"}, 200)
require.Len(t, downloads(), 1)
require.Equal(t, "192.0.2.77", downloads()[0].IP, "identity and IP belong to preparation")
inst.FeatureFlags[publiclink.FlagEnabled] = true
require.NoError(t, instance.Update(inst))
})
t.Run("ConditionalDownloadAttempt", func(t *testing.T) {
id, code := link(rootID)
request("GET", "/files/download/"+rootID+"?Dl=1", code, nil, map[string]string{"If-None-Match": "*"}, 304)
var actions []publiclink.Action
for _, event := range events() {
if event.LinkID == id {
actions = append(actions, event.Action)
}
}
require.ElementsMatch(t, []publiclink.Action{publiclink.Created, publiclink.Downloaded}, actions)
})
t.Run("DownloadStart", func(t *testing.T) {
networkDir, err := vfs.Mkdir(inst.VFS(), "/network", nil)
require.NoError(t, err)
f, err := vfs.Create(inst.VFS(), "/network/large.bin")
require.NoError(t, err)
const size = 16 * 1024 * 1024
data := make([]byte, size)
_, err = rand.Read(data)
require.NoError(t, err)
_, err = f.Write(data)
require.NoError(t, err)
require.NoError(t, f.Close())
doc, err := inst.VFS().FileByPath("/network/large.bin")
require.NoError(t, err)
id, code := link(doc.ID())
archiveID, archiveToken := link(networkDir.ID())
archivePath := related(request("POST", "/files/archive", archiveToken, []byte(`{"data":{"type":"io.cozy.files.archives","attributes":{"name":"network","ids":["`+networkDir.ID()+`"]}}}`), nil, 200))
countDownloads := func(linkID string) int {
n := 0
for _, event := range events() {
if event.LinkID == linkID && event.Action == publiclink.Downloaded {
n++
}
}
return n
}
for _, version := range []string{"HTTP/1.1", "HTTP/2.0"} {
t.Run(version, func(t *testing.T) {
server := httptest.NewUnstartedServer(ts.Config.Handler)
server.EnableHTTP2 = version == "HTTP/2.0"
server.StartTLS()
t.Cleanup(server.Close)
fetch := func(path, token string) *http.Response {
req, err := http.NewRequest("GET", server.URL+path, nil)
require.NoError(t, err)
req.Host = inst.Domain
req.Header.Set("Authorization", "Bearer "+token)
res, err := server.Client().Do(req)
require.NoError(t, err)
require.Equal(t, version, res.Proto)
require.Equal(t, 200, res.StatusCode)
return res
}
before := countDownloads(id)
res := fetch("/files/download/"+doc.ID()+"?Dl=1", code)
n, err := io.Copy(io.Discard, res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValues(t, size, n)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("complete handler did not finish")
}
require.Equal(t, before+1, countDownloads(id))
res = fetch("/files/download/"+doc.ID()+"?Dl=1", code)
_, err = io.CopyN(io.Discard, res.Body, 65536)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("aborted handler did not finish")
}
require.Equal(t, before+2, countDownloads(id), "an aborted download was started")
archiveBefore := countDownloads(archiveID)
res = fetch(archivePath, "")
archiveData, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
reader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
require.NoError(t, err)
require.Len(t, reader.File, 2)
entry, err := reader.File[1].Open()
require.NoError(t, err)
content, err := io.ReadAll(entry)
require.NoError(t, err)
require.NoError(t, entry.Close())
require.Equal(t, data, content)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("archive handler did not finish")
}
require.Equal(t, archiveBefore, countDownloads(archiveID))
res = fetch(archivePath, "")
_, err = io.CopyN(io.Discard, res.Body, 65536)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("aborted archive handler did not finish")
}
require.Equal(t, archiveBefore, countDownloads(archiveID), "aborting a prepared archive does not record another intent")
})
}
})
}