// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2022 mochi-co
// SPDX-FileContributor: mochi-co

package auth

import (
	"testing"

	"github.com/mochi-co/mqtt/v2"
	"github.com/mochi-co/mqtt/v2/packets"
	"github.com/stretchr/testify/require"
)

var (
	checkLedger = Ledger{
		Users: Users{ // users are allowed by default
			"mochi-co": {
				Password: "melon",
				ACL: Filters{
					"d/+/f":      Deny,
					"mochi-co/#": ReadWrite,
					"readonly":   ReadOnly,
				},
			},
			"suspended-username": {
				Password: "any",
				Disallow: true,
			},
			"mochi": { // ACL only, will defer to AuthRules for authentication
				ACL: Filters{
					"special/mochi": ReadOnly,
					"secret/mochi":  Deny,
					"ignored":       ReadWrite,
				},
			},
		},
		Auth: AuthRules{
			{Username: "banned-user"},                               // never allow specific username
			{Remote: "127.0.0.1", Allow: true},                      // always allow localhost
			{Remote: "123.123.123.123"},                             // disallow any from specific address
			{Username: "not-mochi", Remote: "111.144.155.166"},      // disallow specific username and address
			{Remote: "111.*", Allow: true},                          // allow any in wildcard (that isn't the above username)
			{Username: "mochi", Password: "melon", Allow: true},     // allow matching user/pass
			{Username: "mochi-co", Password: "melon", Allow: false}, // allow matching user/pass (should never trigger due to Users map)
		},
		ACL: ACLRules{
			{
				Username: "mochi", // allow matching user/pass
				Filters: Filters{
					"a/b/c":     Deny,
					"d/+/f":     Deny,
					"mochi/#":   ReadWrite,
					"updates/#": WriteOnly,
					"readonly":  ReadOnly,
					"ignored":   Deny,
				},
			},
			{Remote: "localhost", Filters: Filters{"$SYS/#": ReadOnly}}, // allow $SYS access to localhost
			{Username: "admin", Filters: Filters{"$SYS/#": ReadOnly}},   // allow $SYS access to admin
			{Remote: "001.002.003.004"},                                 // Allow all with no filter
			{Filters: Filters{"$SYS/#": Deny}},                          // Deny $SYS access to all others
		},
	}
)

func TestRStringMatches(t *testing.T) {
	require.True(t, RString("*").Matches("any"))
	require.True(t, RString("*").Matches(""))
	require.True(t, RString("").Matches("any"))
	require.True(t, RString("").Matches(""))
	require.False(t, RString("no").Matches("any"))
	require.False(t, RString("no").Matches(""))
}

func TestCanAuthenticate(t *testing.T) {
	tt := []struct {
		desc   string
		client *mqtt.Client
		pk     packets.Packet
		n      int
		ok     bool
	}{
		{
			desc: "allow all local 127.0.0.1",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
				Net: mqtt.ClientConnection{
					Remote: "127.0.0.1",
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{}},
			ok: true,
			n:  1,
		},
		{
			desc: "allow username/password",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
			ok: true,
			n:  5,
		},
		{
			desc: "deny username/password",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
			ok: false,
			n:  0,
		},
		{
			desc: "allow all local 127.0.0.1",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
				Net: mqtt.ClientConnection{
					Remote: "127.0.0.1",
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
			ok: true,
			n:  1,
		},
		{
			desc: "allow username/password",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
			ok: true,
			n:  5,
		},
		{
			desc: "deny username/password",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("bad-pass")}},
			ok: false,
			n:  0,
		},
		{
			desc: "deny client from address",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("not-mochi"),
				},
				Net: mqtt.ClientConnection{
					Remote: "111.144.155.166",
				},
			},
			pk: packets.Packet{},
			ok: false,
			n:  3,
		},
		{
			desc: "allow remote wildcard",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
				Net: mqtt.ClientConnection{
					Remote: "111.0.0.1",
				},
			},
			pk: packets.Packet{},
			ok: true,
			n:  4,
		},
		{
			desc: "never allow username",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("banned-user"),
				},
				Net: mqtt.ClientConnection{
					Remote: "127.0.0.1",
				},
			},
			pk: packets.Packet{},
			ok: false,
			n:  0,
		},
		{
			desc: "matching user in users",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi-co"),
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("melon")}},
			ok: true,
			n:  0,
		},
		{
			desc: "never user in users",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("suspended-user"),
				},
			},
			pk: packets.Packet{Connect: packets.ConnectParams{Password: []byte("any")}},
			ok: false,
			n:  0,
		},
	}

	for _, d := range tt {
		t.Run(d.desc, func(t *testing.T) {
			n, ok := checkLedger.AuthOk(d.client, d.pk)
			require.Equal(t, d.n, n)
			require.Equal(t, d.ok, ok)
		})
	}
}

func TestCanACL(t *testing.T) {
	tt := []struct {
		client *mqtt.Client
		desc   string
		topic  string
		n      int
		write  bool
		ok     bool
	}{
		{
			desc:   "allow normal write on any other filter",
			client: &mqtt.Client{},
			topic:  "default/acl/write/access",
			write:  true,
			ok:     true,
		},
		{
			desc:   "allow normal read on any other filter",
			client: &mqtt.Client{},
			topic:  "default/acl/read/access",
			write:  false,
			ok:     true,
		},
		{
			desc: "deny user on literal filter",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "a/b/c",
		},
		{
			desc: "deny user on partial filter",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "d/j/f",
		},
		{
			desc: "allow read/write to user path",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "mochi/read/write",
			write: true,
			ok:    true,
		},
		{
			desc: "deny read on write-only path",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "updates/no/reading",
			write: false,
			ok:    false,
		},
		{
			desc: "deny read on write-only path ext",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "updates/mochi",
			write: false,
			ok:    false,
		},
		{
			desc: "allow read on not-acl path (no #)",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "updates",
			write: false,
			ok:    true,
		},
		{
			desc: "allow write on write-only path",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "updates/mochi",
			write: true,
			ok:    true,
		},
		{
			desc: "deny write on read-only path",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "readonly",
			write: true,
			ok:    false,
		},
		{
			desc: "allow read on read-only path",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "readonly",
			write: false,
			ok:    true,
		},
		{
			desc: "allow $sys access to localhost",
			client: &mqtt.Client{
				Net: mqtt.ClientConnection{
					Remote: "localhost",
				},
			},
			topic: "$SYS/test",
			write: false,
			ok:    true,
			n:     1,
		},
		{
			desc: "allow $sys access to admin",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("admin"),
				},
			},
			topic: "$SYS/test",
			write: false,
			ok:    true,
			n:     2,
		},
		{
			desc: "deny $sys access to all others",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "$SYS/test",
			write: false,
			ok:    false,
			n:     4,
		},
		{
			desc: "allow all with no filter",
			client: &mqtt.Client{
				Net: mqtt.ClientConnection{
					Remote: "001.002.003.004",
				},
			},
			topic: "any/path",
			write: true,
			ok:    true,
			n:     3,
		},
		{
			desc: "use users embedded acl deny",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "secret/mochi",
			write: true,
			ok:    false,
		},
		{
			desc: "use users embedded acl any",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "any/mochi",
			write: true,
			ok:    true,
		},
		{
			desc: "use users embedded acl write on read-only",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "special/mochi",
			write: true,
			ok:    false,
		},
		{
			desc: "use users embedded acl read on read-only",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "special/mochi",
			write: false,
			ok:    true,
		},
		{
			desc: "preference users embedded acl",
			client: &mqtt.Client{
				Properties: mqtt.ClientProperties{
					Username: []byte("mochi"),
				},
			},
			topic: "ignored",
			write: true,
			ok:    true,
		},
	}

	for _, d := range tt {
		t.Run(d.desc, func(t *testing.T) {
			n, ok := checkLedger.ACLOk(d.client, d.topic, d.write)
			require.Equal(t, d.n, n)
			require.Equal(t, d.ok, ok)
		})
	}
}

func TestMatchTopic(t *testing.T) {
	el, matched := MatchTopic("a/+/c/+", "a/b/c/d")
	require.True(t, matched)
	require.Equal(t, []string{"b", "d"}, el)

	el, matched = MatchTopic("a/+/+/+", "a/b/c/d")
	require.True(t, matched)
	require.Equal(t, []string{"b", "c", "d"}, el)

	el, matched = MatchTopic("stuff/#", "stuff/things/yeah")
	require.True(t, matched)
	require.Equal(t, []string{"things/yeah"}, el)

	el, matched = MatchTopic("a/+/#/+", "a/b/c/d/as/dds")
	require.True(t, matched)
	require.Equal(t, []string{"b", "c/d/as/dds"}, el)

	el, matched = MatchTopic("test", "test")
	require.True(t, matched)
	require.Equal(t, make([]string, 0), el)

	el, matched = MatchTopic("things/stuff//", "things/stuff/")
	require.False(t, matched)
	require.Equal(t, make([]string, 0), el)

	el, matched = MatchTopic("t", "t2")
	require.False(t, matched)
	require.Equal(t, make([]string, 0), el)

	el, matched = MatchTopic(" ", "  ")
	require.False(t, matched)
	require.Equal(t, make([]string, 0), el)
}

var (
	ledgerStruct = Ledger{
		Users: Users{
			"mochi": {
				Password: "peach",
				ACL: Filters{
					"readonly": ReadOnly,
					"deny":     Deny,
				},
			},
		},
		Auth: AuthRules{
			{
				Client:   "*",
				Username: "mochi-co",
				Password: "melon",
				Remote:   "192.168.1.*",
				Allow:    true,
			},
		},
		ACL: ACLRules{
			{
				Client:   "*",
				Username: "mochi-co",
				Remote:   "127.*",
				Filters: Filters{
					"readonly":  ReadOnly,
					"writeonly": WriteOnly,
					"readwrite": ReadWrite,
					"deny":      Deny,
				},
			},
		},
	}

	ledgerJSON = []byte(`{"users":{"mochi":{"password":"peach","acl":{"deny":0,"readonly":1}}},"auth":[{"client":"*","username":"mochi-co","remote":"192.168.1.*","password":"melon","allow":true}],"acl":[{"client":"*","username":"mochi-co","remote":"127.*","filters":{"deny":0,"readonly":1,"readwrite":3,"writeonly":2}}]}`)
	ledgerYAML = []byte(`users:
    mochi:
        password: peach
        acl:
            deny: 0
            readonly: 1
auth:
    - client: '*'
      username: mochi-co
      remote: 192.168.1.*
      password: melon
      allow: true
acl:
    - client: '*'
      username: mochi-co
      remote: 127.*
      filters:
        deny: 0
        readonly: 1
        readwrite: 3
        writeonly: 2
`)
)

func TestLedgerUpdate(t *testing.T) {
	old := &Ledger{
		Auth: AuthRules{
			{Remote: "127.0.0.1", Allow: true},
		},
	}

	new := &Ledger{
		Auth: AuthRules{
			{Remote: "127.0.0.1", Allow: true},
			{Remote: "192.168.*", Allow: true},
		},
	}

	old.Update(new)
	require.Len(t, old.Auth, 2)
	require.Equal(t, RString("192.168.*"), old.Auth[1].Remote)
	require.NotSame(t, new, old)
}

func TestLedgerToJSON(t *testing.T) {
	data, err := ledgerStruct.ToJSON()
	require.NoError(t, err)
	require.Equal(t, ledgerJSON, data)
}

func TestLedgerToYAML(t *testing.T) {
	data, err := ledgerStruct.ToYAML()
	require.NoError(t, err)
	require.Equal(t, ledgerYAML, data)
}

func TestLedgerUnmarshalFromYAML(t *testing.T) {
	l := new(Ledger)
	err := l.Unmarshal(ledgerYAML)
	require.NoError(t, err)
	require.Equal(t, &ledgerStruct, l)
	require.NotSame(t, l, &ledgerStruct)
}

func TestLedgerUnmarshalFromJSON(t *testing.T) {
	l := new(Ledger)
	err := l.Unmarshal(ledgerJSON)
	require.NoError(t, err)
	require.Equal(t, &ledgerStruct, l)
	require.NotSame(t, l, &ledgerStruct)
}

func TestLedgerUnmarshalNil(t *testing.T) {
	l := new(Ledger)
	err := l.Unmarshal([]byte{})
	require.NoError(t, err)
	require.Equal(t, new(Ledger), l)
}
