187 lines
5.2 KiB
Go
187 lines
5.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"send/internal/config"
|
|
)
|
|
|
|
func defaults() *config.Config {
|
|
return &config.Config{
|
|
MaxSize: 2 << 30,
|
|
MaxExpiry: 72 * time.Hour,
|
|
DefaultExpiry: 72 * time.Hour,
|
|
}
|
|
}
|
|
|
|
func newFile(t *testing.T) *File {
|
|
t.Helper()
|
|
f, err := Load(filepath.Join(t.TempDir(), "tokens.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestMissingFileIsNotAnError(t *testing.T) {
|
|
f := newFile(t)
|
|
if len(f.List()) != 0 {
|
|
t.Error("a missing token file produced tokens")
|
|
}
|
|
if f.Lookup("anything") != nil {
|
|
t.Error("a missing token file authenticated something")
|
|
}
|
|
}
|
|
|
|
func TestAddLookupRemove(t *testing.T) {
|
|
f := newFile(t)
|
|
secret := "0123456789abcdef0123456789abcdef"
|
|
if err := f.Add(&Token{Name: "friend", Hash: HashSecret(secret), AllowVanity: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tok := f.Lookup(secret)
|
|
if tok == nil || tok.Name != "friend" {
|
|
t.Fatalf("Lookup(secret) = %v", tok)
|
|
}
|
|
if f.Lookup("wrong") != nil || f.Lookup("") != nil {
|
|
t.Error("an unknown secret authenticated")
|
|
}
|
|
|
|
// A second token with the same name is refused.
|
|
if err := f.Add(&Token{Name: "friend", Hash: HashSecret("other")}); err != ErrExists {
|
|
t.Errorf("duplicate name => %v, want ErrExists", err)
|
|
}
|
|
|
|
if err := f.Remove("friend"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Lookup(secret) != nil {
|
|
t.Error("a removed token still authenticates")
|
|
}
|
|
if err := f.Remove("friend"); err != ErrNotFound {
|
|
t.Errorf("removing twice => %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// The token file holds credential material, so it is the one thing in the data
|
|
// directory that must stay owner-only.
|
|
func TestFilePermissions(t *testing.T) {
|
|
f := newFile(t)
|
|
if err := f.Add(&Token{Name: "a", Hash: HashSecret("s")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(f.Path())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if perm := info.Mode().Perm(); perm != 0o600 {
|
|
t.Errorf("token file mode = %#o, want 0600", perm)
|
|
}
|
|
|
|
// A file loosened by hand must be refused rather than silently used.
|
|
if err := os.Chmod(f.Path(), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(f.Path()); err == nil {
|
|
t.Error("a world-readable token file was accepted")
|
|
}
|
|
}
|
|
|
|
func TestLimitsInheritDefaults(t *testing.T) {
|
|
c := defaults()
|
|
|
|
// A token with nothing set behaves like the anonymous tier, except that it
|
|
// has a name and may claim vanity names.
|
|
bare := &Token{Name: "bare", Hash: HashSecret("bare"), AllowVanity: true}
|
|
got := bare.Limits(c)
|
|
want := Anonymous(c)
|
|
want.Name, want.AllowVanity = "bare", true
|
|
if got != want {
|
|
t.Errorf("bare token limits = %+v, want %+v", got, want)
|
|
}
|
|
|
|
// Overrides win, including "unlimited".
|
|
size, expiry := "8GiB", "never"
|
|
rich := &Token{Name: "rich", Hash: HashSecret("rich"), MaxSize: &size, MaxExpiry: &expiry}
|
|
if err := rich.resolve(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l := rich.Limits(c)
|
|
if l.MaxSize != 8<<30 {
|
|
t.Errorf("MaxSize = %d, want 8GiB", l.MaxSize)
|
|
}
|
|
if l.MaxExpiry != config.Unlimited {
|
|
t.Errorf("MaxExpiry = %s, want unlimited", l.MaxExpiry)
|
|
}
|
|
// The inherited 3d default is still fine under an unlimited maximum.
|
|
if l.DefaultExpiry != c.DefaultExpiry {
|
|
t.Errorf("DefaultExpiry = %s, want the inherited %s", l.DefaultExpiry, c.DefaultExpiry)
|
|
}
|
|
}
|
|
|
|
func TestDefaultExpiryIsClampedToTheMaximum(t *testing.T) {
|
|
c := defaults()
|
|
short := "1h"
|
|
// A token that narrows its maximum below the inherited default must not
|
|
// end up handing out the longer inherited lifetime.
|
|
tok := &Token{Name: "short", Hash: HashSecret("short"), MaxExpiry: &short}
|
|
if err := tok.resolve(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if l := tok.Limits(c); l.DefaultExpiry != time.Hour {
|
|
t.Errorf("DefaultExpiry = %s, want it clamped to 1h", l.DefaultExpiry)
|
|
}
|
|
}
|
|
|
|
func TestMalformedTokenFileIsRejected(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "tokens.json")
|
|
|
|
for _, body := range []string{
|
|
`[{"name":"a","hash":"not-hex"}]`,
|
|
`[{"name":"","hash":"` + HashSecret("s") + `"}]`,
|
|
`[{"name":"a","hash":"` + HashSecret("s") + `","max_size":"lots"}]`,
|
|
`[{"name":"a","hash":"` + HashSecret("s") + `","max_expiry":"soon"}]`,
|
|
`[{"name":"a","hash":"` + HashSecret("1") + `"},{"name":"a","hash":"` + HashSecret("2") + `"}]`,
|
|
`not json`,
|
|
} {
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(path); err == nil {
|
|
t.Errorf("accepted a malformed token file: %s", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReloadPicksUpChanges(t *testing.T) {
|
|
f := newFile(t)
|
|
secret := "aaaa"
|
|
if err := f.Add(&Token{Name: "a", Hash: HashSecret(secret)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Simulate an edit by another process.
|
|
body := `[{"name":"b","hash":"` + HashSecret("bbbb") + `","allow_vanity":true}]`
|
|
if err := os.WriteFile(f.Path(), []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Ensure the mtime actually differs on filesystems with coarse timestamps.
|
|
future := time.Now().Add(time.Second)
|
|
os.Chtimes(f.Path(), future, future)
|
|
|
|
if err := f.MaybeReload(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Lookup(secret) != nil {
|
|
t.Error("a removed token still authenticates after a reload")
|
|
}
|
|
if tok := f.Lookup("bbbb"); tok == nil || !tok.AllowVanity {
|
|
t.Error("the newly written token was not picked up")
|
|
}
|
|
}
|