Files
brianmcgee 1950b9f470 {cmd, pkg}: enforce a maximum ValidFor
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.
2026-04-10 15:33:47 +01:00

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)
}