Introduces a `config.Now` field which all components should defer to when getting the current time instead of hardcoded calls to `model.DefaultClock`.
1456 lines
39 KiB
Go
1456 lines
39 KiB
Go
package state_test
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.clan.lol/clan/data-mesher/pkg/config"
|
|
"git.clan.lol/clan/data-mesher/pkg/crypto"
|
|
"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/stretchr/testify/require"
|
|
)
|
|
|
|
const testFileName = "test_file"
|
|
|
|
func generateNetworkID(t *testing.T) *crypto.PublicKey {
|
|
t.Helper()
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
return keys[0].Public
|
|
}
|
|
|
|
func newTestFiles(t *testing.T) (*config.Config, *state.Files) {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
networkKeys := test.GenerateKeys(t, 1)
|
|
networkID := networkKeys[0].Public
|
|
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly without full validation
|
|
// (validation requires HTTP config which we don't need for these tests)
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
require.NoError(t, err, "failed to create Files")
|
|
|
|
t.Cleanup(func() {
|
|
_ = files.Close()
|
|
})
|
|
|
|
return cfg, files
|
|
}
|
|
|
|
func TestNewFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("create files directory and signature store", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
require.NotNil(t, cfg, "expected cfg to be non-nil")
|
|
require.NotNil(t, files, "expected files to be non-nil")
|
|
})
|
|
|
|
t.Run("create plugin directories under each network", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
networkKeys := test.GenerateKeys(t, 2)
|
|
homeID := networkKeys[0].Public
|
|
extraID := networkKeys[1].Public
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
PluginDirectories: []string{"dns", "nss"},
|
|
Network: config.Network{
|
|
ID: homeID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
|
|
extraNetwork := config.Network{
|
|
ID: extraID,
|
|
Name: "extra",
|
|
Files: config.Files{"test": nil},
|
|
}
|
|
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
homeID.String(): &cfg.Network,
|
|
extraID.String(): &extraNetwork,
|
|
}
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer func() { _ = files.Close() }()
|
|
|
|
// verify plugin dirs exist under both networks
|
|
for _, network := range []*config.Network{&cfg.Network, &extraNetwork} {
|
|
networkPath := path.Join(tmpDir, "files", network.ID.URLEncoded())
|
|
|
|
var info os.FileInfo
|
|
for _, dir := range cfg.PluginDirectories {
|
|
info, err = os.Stat(path.Join(networkPath, dir))
|
|
as.NoError(err, "plugin directory %s should exist under network %s", dir, network.Name)
|
|
as.True(info.IsDir(), "plugin path %s should be a directory", dir)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("fail with invalid path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{
|
|
StateDirectory: "/foo/bar/baz/nonexistent",
|
|
}
|
|
|
|
_, err := state.NewFiles(cfg)
|
|
require.Error(t, err, "expected error for invalid path")
|
|
})
|
|
}
|
|
|
|
func TestFiles_WriteAndGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
content := "foo"
|
|
fileName := testFileName
|
|
|
|
// Sign the content
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
|
|
as.NoError(err, "failed to sign content")
|
|
|
|
// Write the file
|
|
written, err := files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err, "failed to write file")
|
|
as.True(written, "expected file to be written")
|
|
|
|
// Get the file and verify content
|
|
reader, gotSig, err := files.Get(cfg.Network.ID, fileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err, "failed to read file content")
|
|
as.Equal(content, string(gotContent), "content mismatch")
|
|
|
|
// Verify signature fields match
|
|
as.Equal(sig.Type, gotSig.Type, "signature type mismatch")
|
|
as.Equal(sig.Name, gotSig.Name, "signature name mismatch")
|
|
as.True(sig.NetworkID.Equal(gotSig.NetworkID), "network ID mismatch")
|
|
as.Equal(sig.FileSize, gotSig.FileSize, "file size mismatch")
|
|
as.Equal(sig.SignedAt, gotSig.SignedAt, "signed at mismatch")
|
|
as.True(sig.SignedBy.Equal(gotSig.SignedBy), "signed by mismatch")
|
|
as.Equal(sig.Signature, gotSig.Signature, "signature bytes mismatch")
|
|
}
|
|
|
|
func TestFiles_Write_InvalidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
fileName := testFileName
|
|
content := t.Name()
|
|
otherContent := "different content"
|
|
|
|
// Sign different content than what we'll write
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(otherContent))
|
|
as.NoError(err, "failed to sign content")
|
|
|
|
// Attempt to write with mismatched signature
|
|
_, err = files.Put(sig, strings.NewReader(content))
|
|
as.ErrorIs(err, model.ErrSignatureNotValid, "expected ErrSignatureNotValid")
|
|
|
|
// Verify the file was not written
|
|
_, _, err = files.Get(cfg.Network.ID, fileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestFiles_Write_SkipsOlderSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := testFileName
|
|
baseTime := time.Now().UTC()
|
|
|
|
originalContent := "original content"
|
|
olderContent := "older content"
|
|
|
|
// Create clock functions for different times
|
|
newerClock := func() time.Time { return baseTime }
|
|
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
|
|
|
|
// Sign and write the original content with newer timestamp
|
|
originalSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(originalSig, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
|
|
// Sign older content with older timestamp
|
|
olderSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(olderContent))
|
|
as.NoError(err)
|
|
|
|
// Attempt to write with older content and signature
|
|
written, err := files.Put(olderSig, strings.NewReader(olderContent))
|
|
as.NoError(err)
|
|
as.False(written, "expected write to be skipped for older signature")
|
|
|
|
// Verify original content is still there
|
|
reader, _, err := files.Get(cfg.Network.ID, fileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(originalContent, string(gotContent), "original content should be preserved")
|
|
}
|
|
|
|
func TestFiles_Write_UpdatesNewerSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := testFileName
|
|
baseTime := time.Now().UTC()
|
|
|
|
originalContent := "original content"
|
|
newerContent := "newer content"
|
|
|
|
// Create clock functions for different times (both in the past to avoid future-timestamp rejection)
|
|
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
|
|
newerClock := func() time.Time { return baseTime }
|
|
|
|
// Sign and write the original content with older timestamp
|
|
originalSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(originalSig, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
|
|
// Sign newer content with newer timestamp
|
|
newerSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(newerContent))
|
|
as.NoError(err)
|
|
|
|
// Write with newer signature
|
|
written, err := files.Put(newerSig, strings.NewReader(newerContent))
|
|
as.NoError(err)
|
|
as.True(written, "expected write to succeed for newer signature")
|
|
|
|
// Verify newer content is now stored
|
|
reader, _, err := files.Get(cfg.Network.ID, fileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(newerContent, string(gotContent), "newer content should be stored")
|
|
}
|
|
|
|
func TestFiles_Get_NotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
_, _, err := files.Get(cfg.Network.ID, "nonexistent_file")
|
|
require.ErrorIs(t, err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestFiles_Delete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
content := "content to delete"
|
|
fileName := "to_delete"
|
|
|
|
// Sign and write the file
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
_, err = files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
// Delete the file
|
|
err = files.Delete(cfg.Network.ID, fileName)
|
|
as.NoError(err)
|
|
|
|
// Verify file is gone
|
|
_, _, err = files.Get(cfg.Network.ID, fileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestFiles_List_Empty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
_, files := newTestFiles(t)
|
|
|
|
tx, err := files.BeginTx(false)
|
|
as.NoError(err)
|
|
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
reader, err := files.ListSignatures(tx)
|
|
as.NoError(err)
|
|
|
|
buf := make([]model.Signature, 16)
|
|
result := make([]model.Signature, 0)
|
|
|
|
for {
|
|
n, readErr := reader.Read(buf)
|
|
|
|
for i := range n {
|
|
result = append(result, buf[i])
|
|
}
|
|
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
|
|
as.NoError(readErr)
|
|
}
|
|
|
|
as.Empty(result)
|
|
}
|
|
|
|
func TestFiles_List_WithFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Write some files (including path-style names with subdirectories)
|
|
testFiles := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"file1", "content 1"},
|
|
{"subdir/file2", "content 2"},
|
|
{"nested/path/file3", "content 3"},
|
|
}
|
|
|
|
for _, tf := range testFiles {
|
|
sig, err := model.SignFile(tf.name, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(tf.content))
|
|
as.NoError(err)
|
|
_, err = files.Put(sig, strings.NewReader(tf.content))
|
|
as.NoError(err)
|
|
}
|
|
|
|
// List files
|
|
tx, err := files.BeginTx(false)
|
|
as.NoError(err)
|
|
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
reader, err := files.ListSignatures(tx)
|
|
as.NoError(err)
|
|
|
|
buf := make([]model.Signature, 16)
|
|
result := make([]model.Signature, 0)
|
|
|
|
for {
|
|
n, readErr := reader.Read(buf)
|
|
|
|
for i := range n {
|
|
result = append(result, buf[i])
|
|
}
|
|
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
|
|
as.NoError(readErr)
|
|
}
|
|
|
|
as.Len(result, 3)
|
|
|
|
// Check file names and signatures are present
|
|
names := make(map[string]bool)
|
|
for _, sig := range result {
|
|
names[sig.Name] = true
|
|
as.NotNil(sig.SignedBy, "expected SignedBy to be set for %s", sig.Name)
|
|
as.NotEmpty(sig.SignedAt, "expected SignedAt to be set for %s", sig.Name)
|
|
as.NotEmpty(sig.Signature, "expected Signature to be set for %s", sig.Name)
|
|
}
|
|
|
|
as.True(names["file1"])
|
|
as.True(names["subdir/file2"])
|
|
as.True(names["nested/path/file3"])
|
|
}
|
|
|
|
func TestFiles_IntegrityCheck_RemoveMissingFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
// Create a new temp state directory
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Create an initial files store and add some files
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Write two files
|
|
contentOne := "content for file 1"
|
|
contentTwo := "content for file 2"
|
|
|
|
sigOne, err := model.SignFile("file1", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentOne))
|
|
as.NoError(err)
|
|
_, err = files.Put(sigOne, strings.NewReader(contentOne))
|
|
as.NoError(err)
|
|
|
|
sigTwo, err := model.SignFile("file2", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentTwo))
|
|
as.NoError(err)
|
|
_, err = files.Put(sigTwo, strings.NewReader(contentTwo))
|
|
as.NoError(err)
|
|
|
|
// Close the store
|
|
as.NoError(files.Close())
|
|
|
|
// Delete file1.txt from disk (simulate a missing file)
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, "file1")
|
|
as.NoError(os.Remove(filePath))
|
|
|
|
// Re-open the store, which performs an integrity check
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// file1.txt should be gone (signature removed due to missing file)
|
|
_, _, err = files.Get(cfg.Network.ID, "file1")
|
|
as.ErrorIs(err, state.ErrFileNotFound, "file1.txt signature should have been removed")
|
|
|
|
// file2.txt should still exist
|
|
reader, _, err := files.Get(cfg.Network.ID, "file2")
|
|
as.NoError(err, "file2.txt should still exist")
|
|
|
|
content, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(contentTwo, string(content))
|
|
}
|
|
|
|
func TestFiles_IntegrityCheck_RemoveCorruptedFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
// Create a new temp state directory
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Create an initial files store and add some files
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Write two files
|
|
contentOne := "content for file 1"
|
|
contentTwo := "content for file 2"
|
|
|
|
sigOne, err := model.SignFile("file1", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentOne))
|
|
as.NoError(err)
|
|
_, err = files.Put(sigOne, strings.NewReader(contentOne))
|
|
as.NoError(err)
|
|
|
|
sigTwo, err := model.SignFile("file2", cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(contentTwo))
|
|
as.NoError(err)
|
|
_, err = files.Put(sigTwo, strings.NewReader(contentTwo))
|
|
as.NoError(err)
|
|
|
|
// Close the store
|
|
as.NoError(files.Close())
|
|
|
|
// Corrupt file1.txt by changing its content
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, "file1")
|
|
as.NoError(os.WriteFile(filePath, []byte("corrupted content!!!"), 0o600))
|
|
|
|
// Re-open the store, which performs an integrity check
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// file1.txt should be gone (signature removed due to invalid content)
|
|
_, _, err = files.Get(cfg.Network.ID, "file1")
|
|
as.ErrorIs(err, state.ErrFileNotFound, "file1.txt signature should have been removed")
|
|
|
|
// file2.txt should still exist
|
|
reader, _, err := files.Get(cfg.Network.ID, "file2")
|
|
as.NoError(err, "file2.txt should still exist")
|
|
|
|
content, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(contentTwo, string(content))
|
|
}
|
|
|
|
func TestFiles_IntegrityCheck_PreserveValidFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Create an initial files store and add some files
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Write multiple files (including path-style names with subdirectories)
|
|
testFiles := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"file1", "content 1"},
|
|
{"dns/zone1", "content 2"},
|
|
{"dns/zones/zone2", "content 3"},
|
|
}
|
|
|
|
for _, tf := range testFiles {
|
|
sig, sigErr := model.SignFile(tf.name, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(tf.content))
|
|
as.NoError(sigErr)
|
|
|
|
_, putErr := files.Put(sig, strings.NewReader(tf.content))
|
|
as.NoError(putErr)
|
|
}
|
|
|
|
// Close the store
|
|
as.NoError(files.Close())
|
|
|
|
// Re-open the store, which performs an integrity check
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// All files should still exist with the same content
|
|
for _, tf := range testFiles {
|
|
r, _, getErr := files.Get(cfg.Network.ID, tf.name)
|
|
as.NoError(getErr, "expected %s to exist", tf.name)
|
|
|
|
content, readErr := io.ReadAll(r)
|
|
as.NoError(readErr)
|
|
as.Equal(tf.content, string(content), "content mismatch for %s", tf.name)
|
|
}
|
|
}
|
|
|
|
func TestFiles_TempDir_RecreatedOnStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
StateDirectory: t.TempDir(),
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Create an initial files store
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
as.NoError(files.Close())
|
|
|
|
// Simulate leftover partial downloads by creating files in the temp directory
|
|
tmpDir := path.Join(cfg.StateDirectory, state.DirTemp)
|
|
leftoverFile := tmpDir + "/partial-download.tmp"
|
|
as.NoError(os.WriteFile(leftoverFile, []byte("partial content"), 0o600))
|
|
|
|
// Verify the leftover file exists
|
|
_, err = os.Stat(leftoverFile)
|
|
as.NoError(err, "leftover file should exist before restart")
|
|
|
|
// Re-open the store (simulating a restart)
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// The leftover file should have been removed
|
|
_, err = os.Stat(leftoverFile)
|
|
as.True(os.IsNotExist(err), "leftover file should have been removed on startup")
|
|
|
|
// The temp directory should still exist (recreated)
|
|
info, err := os.Stat(tmpDir)
|
|
as.NoError(err, "temp directory should exist")
|
|
as.True(info.IsDir(), "temp path should be a directory")
|
|
}
|
|
|
|
func TestFiles_TempDir_CreatedOnFirstStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
StateDirectory: t.TempDir(),
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Verify temp directory doesn't exist yet
|
|
tmpDir := path.Join(cfg.StateDirectory, state.DirTemp)
|
|
_, err := os.Stat(tmpDir)
|
|
as.True(os.IsNotExist(err), "temp directory should not exist before NewFiles")
|
|
|
|
// Create a new files store
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// The temp directory should now exist
|
|
info, err := os.Stat(tmpDir)
|
|
as.NoError(err, "temp directory should exist after NewFiles")
|
|
as.True(info.IsDir(), "temp path should be a directory")
|
|
}
|
|
|
|
func TestFiles_Tombstone_DeletesFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
content := "hello world"
|
|
|
|
// First, write a file
|
|
sig, err := model.SignFile(testFileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
written, err := files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Verify file exists
|
|
reader, _, err := files.Get(cfg.Network.ID, testFileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(content, string(gotContent))
|
|
|
|
// Create a tombstone with a later timestamp
|
|
laterClock := func() time.Time { return sig.SignedAt.Add(time.Second) }
|
|
tombstone, err := model.SignTombstone(testFileName, cfg.Network.ID, keys[0], laterClock)
|
|
as.NoError(err)
|
|
|
|
// Put the tombstone
|
|
written, err = files.PutTombstone(tombstone)
|
|
as.NoError(err)
|
|
as.True(written, "expected tombstone to be written")
|
|
|
|
// File should no longer be accessible via Get
|
|
_, _, err = files.Get(cfg.Network.ID, testFileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound, "expected file to be deleted")
|
|
}
|
|
|
|
func TestFiles_Tombstone_PreventsOlderFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := "test_file"
|
|
baseTime := time.Now().UTC()
|
|
|
|
// Create a tombstone first
|
|
tombstoneClock := func() time.Time { return baseTime }
|
|
tombstone, err := model.SignTombstone(fileName, cfg.Network.ID, keys[0], tombstoneClock)
|
|
as.NoError(err)
|
|
|
|
written, err := files.PutTombstone(tombstone)
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Try to write an older file (should be rejected)
|
|
olderClock := func() time.Time { return baseTime.Add(-time.Hour) }
|
|
content := "old content"
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
written, err = files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
as.False(written, "expected older file to be rejected")
|
|
|
|
// File should still not exist
|
|
_, _, err = files.Get(cfg.Network.ID, fileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestFiles_Tombstone_ResurrectionWithNewerFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := "test_file"
|
|
baseTime := time.Now().UTC()
|
|
|
|
// Create a tombstone first
|
|
tombstoneClock := func() time.Time { return baseTime.Add(-time.Second) }
|
|
tombstone, err := model.SignTombstone(fileName, cfg.Network.ID, keys[0], tombstoneClock)
|
|
as.NoError(err)
|
|
|
|
written, err := files.PutTombstone(tombstone)
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Write a newer file (resurrection)
|
|
newerClock := func() time.Time { return baseTime }
|
|
content := "resurrected content"
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
written, err = files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
as.True(written, "expected newer file to be written (resurrection)")
|
|
|
|
// File should now exist
|
|
reader, _, err := files.Get(cfg.Network.ID, fileName)
|
|
as.NoError(err)
|
|
|
|
gotContent, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal(content, string(gotContent))
|
|
}
|
|
|
|
func TestFiles_IntegrityCheck_SkipsTombstones(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Create a files store and add a tombstone
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Create a tombstone
|
|
tombstone, err := model.SignTombstone("deleted_file", cfg.Network.ID, keys[0], cfg.Now)
|
|
as.NoError(err)
|
|
|
|
written, err := files.PutTombstone(tombstone)
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Close and reopen (triggers integrity check)
|
|
as.NoError(files.Close())
|
|
|
|
// Reopen - integrity check should not remove the tombstone
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// Tombstone should still be there (Get returns ErrUnauthorizedFile for tombstones)
|
|
_, _, err = files.Get(cfg.Network.ID, "deleted_file")
|
|
as.ErrorIs(err, state.ErrFileNotFound, "tombstone should still prevent file access")
|
|
}
|
|
|
|
func TestFiles_Put_SetsReadonlyPermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer func() {
|
|
_ = files.Close()
|
|
}()
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
content := "test content"
|
|
fileName := "permissions_test"
|
|
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
written, err := files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Stat the file and check its permissions
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
|
|
info, err := os.Stat(filePath)
|
|
as.NoError(err)
|
|
|
|
// The file should be readonly (0o644: owner rw, group/others read-only)
|
|
as.Equal(os.FileMode(0o644), info.Mode().Perm(), "file should have readonly permissions (0644)")
|
|
}
|
|
|
|
func TestFiles_Put_SetsMtimeToSignedAt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
content := "test content"
|
|
fileName := "mtime_test"
|
|
|
|
// Use a specific timestamp in the past
|
|
signedAt := time.Date(2020, 6, 15, 12, 30, 0, 0, time.UTC)
|
|
clock := func() time.Time { return signedAt }
|
|
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], clock, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
written, err := files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Stat the file and check its mtime
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
|
|
info, err := os.Stat(filePath)
|
|
as.NoError(err)
|
|
|
|
// The mtime should match the signature's SignedAt
|
|
as.Equal(signedAt, info.ModTime().UTC(), "file mtime should match signature SignedAt")
|
|
}
|
|
|
|
func TestFiles_Put_UpdatesMtimeOnNewerSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := "mtime_update_test"
|
|
|
|
// First file with older timestamp
|
|
olderTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
olderClock := func() time.Time { return olderTime }
|
|
olderContent := "older content"
|
|
|
|
oldSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], olderClock, strings.NewReader(olderContent))
|
|
as.NoError(err)
|
|
|
|
written, err := files.Put(oldSig, strings.NewReader(olderContent))
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Verify initial mtime
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
|
|
info, err := os.Stat(filePath)
|
|
as.NoError(err)
|
|
as.Equal(olderTime, info.ModTime().UTC(), "initial mtime should match older SignedAt")
|
|
|
|
// Update with newer timestamp
|
|
newerTime := time.Date(2021, 6, 15, 12, 0, 0, 0, time.UTC)
|
|
newerClock := func() time.Time { return newerTime }
|
|
newerContent := "newer content"
|
|
|
|
newerSig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], newerClock, strings.NewReader(newerContent))
|
|
as.NoError(err)
|
|
|
|
written, err = files.Put(newerSig, strings.NewReader(newerContent))
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Verify updated mtime
|
|
info, err = os.Stat(filePath)
|
|
as.NoError(err)
|
|
as.Equal(newerTime, info.ModTime().UTC(), "updated mtime should match newer SignedAt")
|
|
}
|
|
|
|
func TestFiles_Put_RejectsSizeMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := "size_mismatch"
|
|
|
|
// Sign content to get a valid signature
|
|
originalContent := "original content for size test"
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(originalContent))
|
|
as.NoError(err)
|
|
as.Equal(int64(len(originalContent)), sig.FileSize)
|
|
|
|
// Try to put with different content length than declared file size
|
|
shorterContent := "hello"
|
|
_, err = files.Put(sig, strings.NewReader(shorterContent))
|
|
as.ErrorIs(err, model.ErrSignatureNotValid)
|
|
as.ErrorContains(err, "file size mismatch")
|
|
|
|
// File should not have been written
|
|
_, _, err = files.Get(cfg.Network.ID, fileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
}
|
|
|
|
func TestFiles_IntegrityCheck_RemovesTombstonedFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
networkID := generateNetworkID(t)
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkID,
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkID.String(): &cfg.Network,
|
|
}
|
|
|
|
// Create a files store
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
fileName := "orphaned_file"
|
|
content := "this file should be removed"
|
|
|
|
// First, write a file
|
|
sig, err := model.SignFile(fileName, cfg.Network.ID, keys[0], cfg.Now, strings.NewReader(content))
|
|
as.NoError(err)
|
|
|
|
written, err := files.Put(sig, strings.NewReader(content))
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Create a tombstone (this deletes the file from disk)
|
|
laterClock := func() time.Time { return sig.SignedAt.Add(time.Second) }
|
|
tombstone, err := model.SignTombstone(fileName, cfg.Network.ID, keys[0], laterClock)
|
|
as.NoError(err)
|
|
|
|
written, err = files.PutTombstone(tombstone)
|
|
as.NoError(err)
|
|
as.True(written)
|
|
|
|
// Close the store
|
|
as.NoError(files.Close())
|
|
|
|
// Manually recreate the file on disk (simulating an orphaned file)
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, fileName)
|
|
as.NoError(os.MkdirAll(path.Dir(filePath), 0o750))
|
|
as.NoError(os.WriteFile(filePath, []byte(content), 0o600))
|
|
|
|
// Verify the orphaned file exists
|
|
_, err = os.Stat(filePath)
|
|
as.NoError(err, "orphaned file should exist before integrity check")
|
|
|
|
// Reopen the store (triggers integrity check)
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
//nolint:errcheck
|
|
defer files.Close()
|
|
|
|
// The orphaned file should have been removed by the integrity check
|
|
_, err = os.Stat(filePath)
|
|
as.True(os.IsNotExist(err), "orphaned file should have been removed by integrity check")
|
|
|
|
// Get should still return ErrUnauthorizedFile (tombstone is still in place)
|
|
_, _, err = files.Get(cfg.Network.ID, fileName)
|
|
as.ErrorIs(err, state.ErrFileNotFound, "tombstone should still be in effect")
|
|
}
|
|
|
|
func TestFiles_IntegrityCheck_MultipleNetworks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create 3 network IDs; network 0 is the configured network
|
|
networkKeys := test.GenerateKeys(t, 3)
|
|
|
|
networkIDs := make([]*crypto.PublicKey, 3)
|
|
for i := range networkIDs {
|
|
networkIDs[i] = networkKeys[i].Public
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
Now: model.Now,
|
|
StateDirectory: tmpDir,
|
|
Network: config.Network{
|
|
ID: networkIDs[0],
|
|
Files: config.Files{"test": nil},
|
|
},
|
|
ExtraNetworks: config.ExtraNetworks{
|
|
"net1": config.Network{
|
|
ID: networkIDs[1],
|
|
Files: config.Files{"test": nil},
|
|
Name: "net1",
|
|
},
|
|
"net2": config.Network{
|
|
ID: networkIDs[2],
|
|
Files: config.Files{"test": nil},
|
|
Name: "net2",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Populate NetworksByID map directly
|
|
cfg.Network.Name = config.DefaultNetworkName
|
|
net1 := cfg.ExtraNetworks["net1"]
|
|
net2 := cfg.ExtraNetworks["net2"]
|
|
cfg.NetworksByID = map[string]*config.Network{
|
|
networkIDs[0].String(): &cfg.Network,
|
|
networkIDs[1].String(): &net1,
|
|
networkIDs[2].String(): &net2,
|
|
}
|
|
|
|
// Create the files store
|
|
files, err := state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
signerKeys := test.GenerateKeys(t, 1)
|
|
signer := signerKeys[0]
|
|
|
|
const filesPerNetwork = 10
|
|
|
|
// Track file names per network for later verification
|
|
type fileEntry struct {
|
|
name string
|
|
content string
|
|
}
|
|
|
|
allFiles := make([][]fileEntry, 3)
|
|
|
|
for i := range 3 {
|
|
allFiles[i] = make([]fileEntry, filesPerNetwork)
|
|
|
|
for j := range filesPerNetwork {
|
|
name := fmt.Sprintf("net%d/file_%d", i, j)
|
|
content := fmt.Sprintf("content-net%d-file%d", i, j)
|
|
|
|
clock := test.Clock(time.Now().UTC().Add(time.Duration(i*filesPerNetwork+j) * time.Second))
|
|
sig, signErr := model.SignFile(name, networkIDs[i], signer, clock, strings.NewReader(content))
|
|
as.NoError(signErr, "failed to sign %s", name)
|
|
|
|
_, putErr := files.Put(sig, strings.NewReader(content))
|
|
as.NoError(putErr, "failed to put %s", name)
|
|
|
|
allFiles[i][j] = fileEntry{name: name, content: content}
|
|
}
|
|
}
|
|
|
|
// Close the store before corrupting files
|
|
as.NoError(files.Close())
|
|
|
|
// Extract per-network locals for readability.
|
|
net0ID := networkIDs[0]
|
|
net0Files := allFiles[0]
|
|
net1ID := networkIDs[1]
|
|
net1Files := allFiles[1]
|
|
net2ID := networkIDs[2]
|
|
net2Files := allFiles[2]
|
|
|
|
// --- Introduce corruption ---
|
|
|
|
// Network 0 (configured network): delete 2 files, corrupt 2 files, leave 6 intact
|
|
// Delete files 0 and 1
|
|
for _, idx := range []int{0, 1} {
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, net0Files[idx].name)
|
|
as.NoError(os.Remove(filePath), "failed to delete %s", filePath)
|
|
}
|
|
|
|
// Corrupt files 2 and 3
|
|
for _, idx := range []int{2, 3} {
|
|
filePath := path.Join(tmpDir, state.DirFiles, config.DefaultNetworkName, net0Files[idx].name)
|
|
as.NoError(os.WriteFile(filePath, []byte("garbage-data"), 0o600), "failed to corrupt %s", filePath)
|
|
}
|
|
|
|
// Network 1: delete 1 file (file 0)
|
|
{
|
|
filePath := path.Join(tmpDir, state.DirFiles, "net1", net1Files[0].name)
|
|
as.NoError(os.Remove(filePath), "failed to delete %s", filePath)
|
|
}
|
|
|
|
// Network 2: all intact (no changes)
|
|
|
|
// --- Reopen the store (triggers checkIntegrity) ---
|
|
files, err = state.NewFiles(cfg)
|
|
as.NoError(err)
|
|
|
|
defer files.Close() //nolint:errcheck
|
|
|
|
// --- Verify network 0 ---
|
|
// Files 0-3 should be gone (2 deleted + 2 corrupted), files 4-9 should be intact
|
|
for idx := range 4 {
|
|
_, _, getErr := files.Get(net0ID, net0Files[idx].name)
|
|
as.ErrorIs(getErr, state.ErrFileNotFound,
|
|
"network 0 file %d (%s) should be gone", idx, net0Files[idx].name)
|
|
}
|
|
|
|
for idx := 4; idx < filesPerNetwork; idx++ {
|
|
reader, _, getErr := files.Get(net0ID, net0Files[idx].name)
|
|
as.NoError(getErr,
|
|
"network 0 file %d (%s) should be accessible", idx, net0Files[idx].name)
|
|
|
|
content, readErr := io.ReadAll(reader)
|
|
as.NoError(readErr)
|
|
as.Equal(net0Files[idx].content, string(content))
|
|
}
|
|
|
|
// --- Verify network 1 ---
|
|
// File 0 was deleted from disk, so integrity check should have removed its signature
|
|
_, _, getErr := files.Get(net1ID, net1Files[0].name)
|
|
as.ErrorIs(getErr, state.ErrFileNotFound,
|
|
"network 1 file 0 should have been removed by integrity check")
|
|
|
|
// The remaining 9 intact files from network 1 should be accessible
|
|
for idx := 1; idx < filesPerNetwork; idx++ {
|
|
reader, _, getErr := files.Get(net1ID, net1Files[idx].name)
|
|
as.NoError(getErr,
|
|
"network 1 file %d (%s) should be accessible", idx, net1Files[idx].name)
|
|
|
|
content, readErr := io.ReadAll(reader)
|
|
as.NoError(readErr)
|
|
as.Equal(net1Files[idx].content, string(content))
|
|
}
|
|
|
|
// --- Verify network 2 ---
|
|
// All 10 files should be intact
|
|
for idx := range filesPerNetwork {
|
|
reader, _, getErr := files.Get(net2ID, net2Files[idx].name)
|
|
as.NoError(getErr,
|
|
"network 2 file %d (%s) should be accessible", idx, net2Files[idx].name)
|
|
|
|
content, readErr := io.ReadAll(reader)
|
|
as.NoError(readErr)
|
|
as.Equal(net2Files[idx].content, string(content))
|
|
}
|
|
}
|
|
|
|
// writeWithTTL is a small test helper that signs and writes a file with the
|
|
// given clock and validFor, mirroring how a CLI client would publish.
|
|
func writeWithTTL(
|
|
t *testing.T,
|
|
files *state.Files,
|
|
networkID *crypto.PublicKey,
|
|
signer *crypto.PrivateKey,
|
|
clock model.Clock,
|
|
validFor time.Duration,
|
|
name, content string,
|
|
) {
|
|
t.Helper()
|
|
|
|
sig, err := model.SignFileWithTTL(name, networkID, signer, clock, validFor, strings.NewReader(content))
|
|
require.NoError(t, err)
|
|
|
|
written, err := files.Put(sig, strings.NewReader(content))
|
|
require.NoError(t, err)
|
|
require.True(t, written)
|
|
}
|
|
|
|
func TestFiles_Get_ExpiredReturnsNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Sign with SignedAt 1 hour in the past, ValidFor 1 minute → already
|
|
// expired by the time Get runs against the real clock.
|
|
pastClock := func() time.Time { return time.Now().Add(-1 * time.Hour) }
|
|
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, time.Minute, "expired", "stale content")
|
|
|
|
_, _, err := files.Get(cfg.Network.ID, "expired")
|
|
as.ErrorIs(err, state.ErrFileNotFound, "expected expired file to be hidden from Get")
|
|
}
|
|
|
|
func TestFiles_Get_FutureExpiryReturnsFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// Sign now with a generous ValidFor — should be readable.
|
|
writeWithTTL(t, files, cfg.Network.ID, keys[0], cfg.Now, time.Hour, "fresh", "live content")
|
|
|
|
reader, sig, err := files.Get(cfg.Network.ID, "fresh")
|
|
as.NoError(err)
|
|
as.Equal(time.Hour, sig.ValidFor)
|
|
|
|
got, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal("live content", string(got))
|
|
}
|
|
|
|
func TestFiles_Get_ZeroValidForReturnsFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
// ValidFor=0 means "no expiry" — Get must return the file regardless of
|
|
// how old SignedAt is.
|
|
pastClock := func() time.Time { return time.Now().Add(-100 * 365 * 24 * time.Hour) }
|
|
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, 0, "ancient", "still good")
|
|
|
|
reader, _, err := files.Get(cfg.Network.ID, "ancient")
|
|
as.NoError(err)
|
|
|
|
got, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal("still good", string(got))
|
|
}
|
|
|
|
func TestFiles_SweepExpired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as := require.New(t)
|
|
|
|
cfg, files := newTestFiles(t)
|
|
|
|
keys := test.GenerateKeys(t, 1)
|
|
|
|
freshClock := cfg.Now
|
|
pastClock := func() time.Time { return cfg.Now().Add(-1 * time.Hour) }
|
|
|
|
// One expired (SignedAt past, short TTL), one not expired (long TTL),
|
|
// one no-expiry (TTL=0).
|
|
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, time.Minute, "expired", "stale")
|
|
writeWithTTL(t, files, cfg.Network.ID, keys[0], freshClock, time.Hour, "fresh", "live")
|
|
writeWithTTL(t, files, cfg.Network.ID, keys[0], pastClock, 0, "forever", "no expiry")
|
|
|
|
// Sweep at "now": only "expired" should be removed.
|
|
removed, err := files.SweepExpired(freshClock())
|
|
as.NoError(err)
|
|
as.Equal(1, removed, "exactly one expired entry should have been swept")
|
|
|
|
// Confirm the expired sig + file are gone.
|
|
_, _, err = files.Get(cfg.Network.ID, "expired")
|
|
as.ErrorIs(err, state.ErrFileNotFound)
|
|
|
|
// Confirm the fresh sig is still readable.
|
|
reader, _, err := files.Get(cfg.Network.ID, "fresh")
|
|
as.NoError(err)
|
|
|
|
got, err := io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal("live", string(got))
|
|
|
|
// Confirm the no-expiry sig is still readable.
|
|
reader, _, err = files.Get(cfg.Network.ID, "forever")
|
|
as.NoError(err)
|
|
|
|
got, err = io.ReadAll(reader)
|
|
as.NoError(err)
|
|
as.Equal("no expiry", string(got))
|
|
|
|
// Idempotency: a second sweep should be a no-op.
|
|
removed, err = files.SweepExpired(time.Now())
|
|
as.NoError(err)
|
|
as.Equal(0, removed, "second sweep should remove nothing")
|
|
}
|