Without an upper bound, a client or malicious peer could set `ValidFor` to an absurdly large duration (e.g. 290 years), which is functionally equivalent to no expiry. Add `config.MaxValidFor` (default 30 days) and enforce it at all three ingress points: HTTP handler, CLI, and gossip import filter.
1115 lines
30 KiB
Go
1115 lines
30 KiB
Go
package http_test
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.clan.lol/clan/data-mesher/pkg/config"
|
|
"git.clan.lol/clan/data-mesher/pkg/crypto"
|
|
dmhttp "git.clan.lol/clan/data-mesher/pkg/http"
|
|
"git.clan.lol/clan/data-mesher/pkg/model"
|
|
"git.clan.lol/clan/data-mesher/pkg/state"
|
|
"git.clan.lol/clan/data-mesher/test"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func setAllSignatureHeaders(req *http.Request, sig *model.Signature) {
|
|
req.Header.Set(dmhttp.HeaderSignedAt, sig.SignedAt.Format(time.RFC3339Nano))
|
|
req.Header.Set(dmhttp.HeaderSignedBy, sig.SignedBy.String())
|
|
req.Header.Set(dmhttp.HeaderSignature, base64.StdEncoding.EncodeToString(sig.Signature))
|
|
|
|
if sig.NetworkID != nil {
|
|
req.Header.Set(dmhttp.HeaderNetworkID, sig.NetworkID.String())
|
|
}
|
|
|
|
if sig.Certificate != nil {
|
|
req.Header.Set(dmhttp.HeaderCertificate, sig.Certificate.String())
|
|
}
|
|
}
|
|
|
|
const (
|
|
testContent = "hello world"
|
|
testFileName = "test_file"
|
|
namespaceFileContent = "namespace file content"
|
|
testNamespaceName = "dns"
|
|
)
|
|
|
|
// newTestServer creates a test server with optional file signer configuration.
|
|
// fileSigners maps file names to allowed public keys for signing.
|
|
func newTestServer(
|
|
t *testing.T,
|
|
networkID *crypto.PublicKey,
|
|
fileSigners map[string][]*crypto.PublicKey,
|
|
) (*dmhttp.Server, *config.Config, *state.Files, func()) {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
MaxFileSize: config.DefaultMaxFileSize,
|
|
MaxValidFor: config.DefaultMaxValidFor,
|
|
Now: model.Now,
|
|
Network: config.Network{ID: networkID, Files: fileSigners},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
cfg.SignatureAuth = config.NewSignatureAuth(cfg)
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
require.NoError(t, err, "failed to create Files")
|
|
|
|
// Create a test server instance
|
|
srv := dmhttp.NewTestServer(
|
|
slog.New(slog.DiscardHandler),
|
|
cfg,
|
|
files,
|
|
echo.New(),
|
|
)
|
|
|
|
cleanup := func() {
|
|
_ = files.Close()
|
|
}
|
|
|
|
return srv, cfg, files, cleanup
|
|
}
|
|
|
|
func setSignatureHeaders(req *http.Request, sig *model.Signature) {
|
|
req.Header.Set(dmhttp.HeaderSignedAt, sig.SignedAt.Format(time.RFC3339Nano))
|
|
req.Header.Set(dmhttp.HeaderSignedBy, sig.SignedBy.String())
|
|
req.Header.Set(dmhttp.HeaderSignature, base64.StdEncoding.EncodeToString(sig.Signature))
|
|
}
|
|
|
|
func TestPutFile_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Sign the content
|
|
sig, err := model.SignFile(testFileName, networkID, signerKey, cfg.Now, strings.NewReader(testContent))
|
|
as.NoError(err, "failed to sign content")
|
|
|
|
// Create request
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(testContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusNoContent, rec.Code)
|
|
|
|
// Verify file was written
|
|
reader, gotSig, err := files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(testContent, string(gotContent))
|
|
as.Equal(sig.SignedAt.UTC(), gotSig.SignedAt.UTC())
|
|
}
|
|
|
|
func TestPutFile_InvalidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
otherContent := "different content"
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Sign different content than what we'll send
|
|
sig, err := model.SignFile(testFileName, networkID, signerKey, cfg.Now, strings.NewReader(otherContent))
|
|
as.NoError(err, "failed to sign content")
|
|
|
|
// Create request with mismatched body
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(testContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err = srv.PutFile(c)
|
|
as.NoError(err) // Handler returns error via response, not Go error
|
|
as.Equal(http.StatusInternalServerError, rec.Code)
|
|
|
|
// Verify file was not written
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestPutFile_ConflictOlderVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
baseTime := time.Now().UTC()
|
|
|
|
srv, _, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
originalContent := "original content"
|
|
olderContent := "older content"
|
|
|
|
newerClock := func() time.Time { return baseTime }
|
|
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
|
|
|
|
// Write original file with newer timestamp directly to store
|
|
originalSig, err := model.SignFile(testFileName, networkID, signerKey, newerClock, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(originalSig, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
|
|
// Create request with older timestamp
|
|
olderSig, err := model.SignFile(testFileName, networkID, signerKey, olderClock, strings.NewReader(olderContent))
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(olderContent),
|
|
)
|
|
setSignatureHeaders(req, olderSig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusConflict, rec.Code)
|
|
as.Contains(rec.Body.String(), "newer version")
|
|
|
|
// Verify original content is still there
|
|
reader, _, err := files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(originalContent, string(gotContent))
|
|
}
|
|
|
|
func TestPutFile_BadRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
networkKey := test.GenerateKeys(t, 1)[0]
|
|
networkID := networkKey.Public
|
|
|
|
srv, _, _, cleanup := newTestServer(t, networkID, nil)
|
|
defer cleanup()
|
|
|
|
// Create request without required headers
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader("content"))
|
|
// Missing X-SignedAt, X-SignedBy, X-Signature headers
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err := srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusBadRequest, rec.Code)
|
|
}
|
|
|
|
func TestPutFile_UnconfiguredFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
fileName := "unconfigured_file"
|
|
|
|
// Server has no files configured
|
|
srv, cfg, _, cleanup := newTestServer(t, networkID, nil)
|
|
defer cleanup()
|
|
|
|
sig, err := model.SignFile(fileName, networkID, signerKey, cfg.Now, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/file/"+fileName, strings.NewReader(testContent))
|
|
setSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(fileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusForbidden, rec.Code)
|
|
as.Contains(rec.Body.String(), "not authorized")
|
|
}
|
|
|
|
func TestPutFile_UnauthorizedSigner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 3)
|
|
keys, networkKey := allKeys[:2], allKeys[2]
|
|
networkID := networkKey.Public
|
|
|
|
// Configure file with keys[0], but sign with keys[1]
|
|
srv, cfg, _, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {keys[0].Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Sign with unauthorized key
|
|
sig, err := model.SignFile(testFileName, networkID, keys[1], cfg.Now, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(testContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusForbidden, rec.Code)
|
|
as.Contains(rec.Body.String(), "not authorized")
|
|
}
|
|
|
|
func TestListFiles_Empty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
networkKey := test.GenerateKeys(t, 1)[0]
|
|
networkID := networkKey.Public
|
|
|
|
srv, _, _, cleanup := newTestServer(t, networkID, nil)
|
|
defer cleanup()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/files", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
|
|
err := srv.ListFiles(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusOK, rec.Code)
|
|
as.Empty(rec.Body.String()) // empty NDJSON is empty body
|
|
}
|
|
|
|
func TestListFiles_WithFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
networkKey, signerKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, nil)
|
|
defer cleanup()
|
|
|
|
// Write some files
|
|
testFiles := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"file1", "content 1"},
|
|
{"file2", "content 2"},
|
|
{"file3", "content 3"},
|
|
}
|
|
|
|
for _, f := range testFiles {
|
|
sig, err := model.SignFile(f.name, networkID, signerKey, cfg.Now, strings.NewReader(f.content))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(sig, strings.NewReader(f.content))
|
|
as.NoError(err)
|
|
}
|
|
|
|
// List files
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/files", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
|
|
err := srv.ListFiles(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusOK, rec.Code)
|
|
|
|
// Parse NDJSON response (one JSON object per line)
|
|
var result []model.Signature
|
|
|
|
dec := json.NewDecoder(rec.Body)
|
|
|
|
for dec.More() {
|
|
var file model.Signature
|
|
|
|
err = dec.Decode(&file)
|
|
as.NoError(err)
|
|
|
|
result = append(result, file)
|
|
}
|
|
|
|
as.Len(result, 3)
|
|
|
|
// Check file names are present
|
|
names := make(map[string]bool)
|
|
|
|
for _, f := range result {
|
|
names[f.Name] = true
|
|
as.NotNil(f.SignedBy, "expected SignedBy to be set")
|
|
}
|
|
|
|
as.True(names["file1"])
|
|
as.True(names["file2"])
|
|
as.True(names["file3"])
|
|
}
|
|
|
|
func TestDeleteFile_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// First write a file
|
|
fileSig, err := model.SignFile(testFileName, networkID, signerKey, cfg.Now, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(fileSig, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
// Verify file exists
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
|
|
// Create tombstone with later timestamp
|
|
laterClock := func() time.Time { return fileSig.SignedAt.Add(time.Second) }
|
|
tombstone, err := model.SignTombstone(testFileName, networkID, signerKey, laterClock)
|
|
as.NoError(err)
|
|
|
|
// Create DELETE request
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/file/"+testFileName, nil)
|
|
setSignatureHeaders(req, tombstone)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err = srv.DeleteFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusNoContent, rec.Code)
|
|
|
|
// Verify file is now deleted (tombstone applied)
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestDeleteFile_InvalidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 3)
|
|
keys, networkKey := allKeys[:2], allKeys[2]
|
|
networkID := networkKey.Public
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {keys[0].Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// First write a file
|
|
fileSig, err := model.SignFile(testFileName, networkID, keys[0], cfg.Now, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(fileSig, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
// Create tombstone signed by wrong key (keys[1])
|
|
tombstone, err := model.SignTombstone(testFileName, networkID, keys[1], cfg.Now)
|
|
as.NoError(err)
|
|
|
|
// Manually modify the tombstone to use keys[0] public key but keep wrong signature
|
|
tombstone.SignedBy = keys[0].Public
|
|
|
|
// Create DELETE request
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/file/"+testFileName, nil)
|
|
setSignatureHeaders(req, tombstone)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err = srv.DeleteFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusInternalServerError, rec.Code)
|
|
|
|
// Verify file still exists
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
}
|
|
|
|
func TestDeleteFile_UnauthorizedSigner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 3)
|
|
keys, networkKey := allKeys[:2], allKeys[2]
|
|
networkID := networkKey.Public
|
|
|
|
// Configure file with keys[0], but sign tombstone with keys[1]
|
|
srv, cfg, _, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {keys[0].Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Sign tombstone with unauthorized key
|
|
tombstone, err := model.SignTombstone(testFileName, networkID, keys[1], cfg.Now)
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/file/"+testFileName, nil)
|
|
setSignatureHeaders(req, tombstone)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
err = srv.DeleteFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusForbidden, rec.Code)
|
|
as.Contains(rec.Body.String(), "not authorized")
|
|
}
|
|
|
|
func TestDeleteFile_UnconfiguredFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
fileName := "unconfigured_file"
|
|
|
|
// Server has no files configured
|
|
srv, cfg, _, cleanup := newTestServer(t, networkID, nil)
|
|
defer cleanup()
|
|
|
|
tombstone, err := model.SignTombstone(fileName, networkID, signerKey, cfg.Now)
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/file/"+fileName, nil)
|
|
setSignatureHeaders(req, tombstone)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(fileName)
|
|
|
|
err = srv.DeleteFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusForbidden, rec.Code)
|
|
as.Contains(rec.Body.String(), "not authorized")
|
|
}
|
|
|
|
func TestDeleteFile_ConflictOlderTombstone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
baseTime := time.Now().UTC()
|
|
|
|
srv, _, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Write a file with a newer timestamp
|
|
newerClock := func() time.Time { return baseTime }
|
|
fileSig, err := model.SignFile(testFileName, networkID, signerKey, newerClock, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(fileSig, strings.NewReader(testContent))
|
|
as.NoError(err)
|
|
|
|
// Try to delete with an older tombstone
|
|
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
|
|
tombstone, err := model.SignTombstone(testFileName, networkID, signerKey, olderClock)
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/file/"+testFileName, nil)
|
|
setSignatureHeaders(req, tombstone)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
err = srv.DeleteFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusConflict, rec.Code)
|
|
as.Contains(rec.Body.String(), "newer version")
|
|
|
|
// Verify file still exists
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
}
|
|
|
|
func TestPutFile_ExceedsMaxFileSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
// Create a server with a small max file size
|
|
tmpDir := t.TempDir()
|
|
maxFileSize := int64(10) // 10 bytes
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
MaxFileSize: maxFileSize,
|
|
MaxValidFor: config.DefaultMaxValidFor,
|
|
Now: model.Now,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{testFileName: {signerKey.Public}},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
cfg.SignatureAuth = config.NewSignatureAuth(cfg)
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
srv := dmhttp.NewTestServer(
|
|
slog.New(slog.DiscardHandler),
|
|
cfg,
|
|
files,
|
|
echo.New(),
|
|
)
|
|
|
|
// Create content larger than max file size
|
|
largeContent := "this content is definitely larger than 10 bytes"
|
|
as.Greater(int64(len(largeContent)), maxFileSize)
|
|
|
|
// Sign the content
|
|
sig, err := model.SignFile(testFileName, networkID, signerKey, cfg.Now, strings.NewReader(largeContent))
|
|
as.NoError(err)
|
|
|
|
// Create request
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(largeContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusRequestEntityTooLarge, rec.Code)
|
|
as.Contains(rec.Body.String(), "file too large")
|
|
|
|
// Verify file was not written
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestPutFile_ExactlyMaxFileSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
// Create a server with max file size matching our content
|
|
content := "exactly10!" // 10 bytes
|
|
maxFileSize := int64(len(content))
|
|
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
MaxFileSize: maxFileSize,
|
|
MaxValidFor: config.DefaultMaxValidFor,
|
|
Now: model.Now,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{testFileName: {signerKey.Public}},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
cfg.SignatureAuth = config.NewSignatureAuth(cfg)
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
srv := dmhttp.NewTestServer(
|
|
slog.New(slog.DiscardHandler),
|
|
cfg,
|
|
files,
|
|
echo.New(),
|
|
)
|
|
|
|
// Sign the content
|
|
sig, err := model.SignFile(testFileName, networkID, signerKey, cfg.Now, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
// Create request
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(content))
|
|
setSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
// Call handler - should succeed since size equals max
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusNoContent, rec.Code)
|
|
|
|
// Verify file was written
|
|
reader, _, err := files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(content, string(gotContent))
|
|
}
|
|
|
|
func TestPutFile_NamespaceSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
content := namespaceFileContent
|
|
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate("peer1", signerKey.Public, networkKey, now.Add(-time.Hour), now.Add(time.Hour))
|
|
as.NoError(err)
|
|
|
|
namespaceName := testNamespaceName
|
|
fileName := path.Join(namespaceName, signerKey.Public.URLEncoded())
|
|
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
MaxFileSize: config.DefaultMaxFileSize,
|
|
MaxValidFor: config.DefaultMaxValidFor,
|
|
Now: model.Now,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"placeholder": {signerKey.Public}},
|
|
Namespaces: []string{namespaceName},
|
|
},
|
|
}
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
cfg.SignatureAuth = config.NewSignatureAuth(cfg)
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
srv := dmhttp.NewTestServer(slog.New(slog.DiscardHandler), cfg, files, echo.New())
|
|
|
|
// Sign the content
|
|
sig, err := model.SignFile(fileName, networkID, signerKey, func() time.Time { return now }, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
sig.Certificate = cert
|
|
|
|
// Create request with all headers including NetworkID and Certificate
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/file/"+fileName, strings.NewReader(content))
|
|
setAllSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(fileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusNoContent, rec.Code)
|
|
|
|
// Verify file was written
|
|
reader, _, err := files.Get(networkID, fileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(content, string(gotContent))
|
|
}
|
|
|
|
func TestPutFile_NamespaceMissingCertificate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
content := namespaceFileContent
|
|
|
|
now := time.Now()
|
|
namespaceName := testNamespaceName
|
|
fileName := path.Join(namespaceName, signerKey.Public.URLEncoded())
|
|
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
MaxFileSize: config.DefaultMaxFileSize,
|
|
MaxValidFor: config.DefaultMaxValidFor,
|
|
Now: model.Now,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"placeholder": {signerKey.Public}},
|
|
Namespaces: []string{namespaceName},
|
|
},
|
|
}
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
cfg.SignatureAuth = config.NewSignatureAuth(cfg)
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
srv := dmhttp.NewTestServer(slog.New(slog.DiscardHandler), cfg, files, echo.New())
|
|
|
|
// Sign without certificate
|
|
sig, err := model.SignFile(fileName, networkID, signerKey, func() time.Time { return now }, strings.NewReader(content))
|
|
as.NoError(err)
|
|
// no cert attached
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/file/"+fileName, strings.NewReader(content))
|
|
setAllSignatureHeaders(req, sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(fileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusForbidden, rec.Code)
|
|
}
|
|
|
|
func TestPutFile_NamespaceDefaultsToHomeNetwork(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
content := namespaceFileContent
|
|
|
|
now := time.Now()
|
|
cert, err := crypto.SignCertificate("peer1", signerKey.Public, networkKey, now.Add(-time.Hour), now.Add(time.Hour))
|
|
as.NoError(err)
|
|
|
|
namespaceName := testNamespaceName
|
|
fileName := path.Join(namespaceName, signerKey.Public.URLEncoded())
|
|
|
|
srv, _, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
"placeholder": {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Manually add namespaces to the config (newTestServer doesn't support it)
|
|
// Instead, build config with namespaces
|
|
tmpDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
MaxFileSize: config.DefaultMaxFileSize,
|
|
MaxValidFor: config.DefaultMaxValidFor,
|
|
Now: model.Now,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"placeholder": {signerKey.Public}},
|
|
Namespaces: []string{namespaceName},
|
|
},
|
|
}
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
cfg.SignatureAuth = config.NewSignatureAuth(cfg)
|
|
|
|
files2, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files2.Close() //nolint:errcheck
|
|
|
|
_ = srv
|
|
_ = files
|
|
|
|
srv2 := dmhttp.NewTestServer(slog.New(slog.DiscardHandler), cfg, files2, echo.New())
|
|
|
|
// Sign with home network ID but don't include X-Network-Id header
|
|
sig, err := model.SignFile(fileName, networkID, signerKey, func() time.Time { return now }, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
sig.Certificate = cert
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/file/"+fileName, strings.NewReader(content))
|
|
// Set headers manually without NetworkID to test defaulting
|
|
req.Header.Set(dmhttp.HeaderSignedAt, sig.SignedAt.Format(time.RFC3339Nano))
|
|
req.Header.Set(dmhttp.HeaderSignedBy, sig.SignedBy.String())
|
|
req.Header.Set(dmhttp.HeaderSignature, base64.StdEncoding.EncodeToString(sig.Signature))
|
|
req.Header.Set(dmhttp.HeaderCertificate, sig.Certificate.String())
|
|
// deliberately NOT setting X-Network-Id
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv2.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(fileName)
|
|
|
|
err = srv2.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusNoContent, rec.Code)
|
|
}
|
|
|
|
func TestPutFile_RejectsAlreadyExpired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
srv, _, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Sign with SignedAt 1h in the past, ValidFor 1m → already expired by
|
|
// the time the daemon evaluates the request.
|
|
pastClock := func() time.Time { return time.Now().Add(-1 * time.Hour) }
|
|
sig, err := model.SignFileWithTTL(
|
|
testFileName, networkID, signerKey, pastClock, time.Minute, strings.NewReader(testContent),
|
|
)
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(testContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
req.Header.Set(dmhttp.HeaderValidFor, sig.ValidFor.String())
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err) // handler returns the error via response code, not Go error
|
|
as.Equal(http.StatusBadRequest, rec.Code, "expected 400 for already-expired PUT")
|
|
|
|
// Confirm nothing was written
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestPutFile_RejectsExcessiveValidFor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
// Sign with a ValidFor that exceeds MaxValidFor.
|
|
excessive := cfg.MaxValidFor + time.Hour
|
|
sig, err := model.SignFileWithTTL(
|
|
testFileName, networkID, signerKey, cfg.Now, excessive, strings.NewReader(testContent),
|
|
)
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(testContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
req.Header.Set(dmhttp.HeaderValidFor, sig.ValidFor.String())
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusBadRequest, rec.Code, "expected 400 for excessive ValidFor")
|
|
as.Contains(rec.Body.String(), "exceeds maximum")
|
|
|
|
// Confirm nothing was written.
|
|
_, _, err = files.Get(networkID, testFileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestPutFile_AcceptsLiveTTL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
allKeys := test.GenerateKeys(t, 2)
|
|
signerKey, networkKey := allKeys[0], allKeys[1]
|
|
networkID := networkKey.Public
|
|
|
|
srv, cfg, files, cleanup := newTestServer(t, networkID, map[string][]*crypto.PublicKey{
|
|
testFileName: {signerKey.Public},
|
|
})
|
|
defer cleanup()
|
|
|
|
sig, err := model.SignFileWithTTL(
|
|
testFileName, networkID, signerKey, cfg.Now, time.Hour, strings.NewReader(testContent),
|
|
)
|
|
as.NoError(err)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodPut, "/file/"+testFileName, strings.NewReader(testContent),
|
|
)
|
|
setSignatureHeaders(req, sig)
|
|
req.Header.Set(dmhttp.HeaderValidFor, sig.ValidFor.String())
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := srv.Echo().NewContext(req, rec)
|
|
c.SetPath("/file/:name")
|
|
c.SetParamNames("name")
|
|
c.SetParamValues(testFileName)
|
|
|
|
err = srv.PutFile(c)
|
|
as.NoError(err)
|
|
as.Equal(http.StatusNoContent, rec.Code, "expected 204 for live-TTL PUT")
|
|
|
|
// Confirm the file is readable and the ValidFor was persisted.
|
|
_, gotSig, err := files.Get(networkID, testFileName)
|
|
as.NoError(err)
|
|
as.Equal(time.Hour, gotSig.ValidFor)
|
|
}
|