package store import ( "context" "path/filepath" "testing" "time" "jarl/internal/model" ) func TestStateRoundTrip(t *testing.T) { ctx := context.Background() s, err := Open(filepath.Join(t.TempDir(), "jarl.db")) if err != nil { t.Fatal(err) } defer s.Close() ids, err := s.AllocateIDs(ctx, 5) if err != nil || len(ids) != 5 || ids[0] != 1 || ids[4] != 5 { t.Fatalf("AllocateIDs = %v %v", ids, err) } ids2, _ := s.AllocateIDs(ctx, 2) if ids2[0] != 6 { t.Fatalf("second allocation = %v", ids2) } now := time.Now().UTC().Truncate(time.Millisecond) st := model.NewState() st.Settings.Nameservers = []string{"ns1.example.com."} meta := model.Meta{Version: 1, CreatedAt: now, UpdatedAt: now, CreatedBy: "adam", UpdatedBy: "adam"} st.Views[1] = &model.View{ID: 1, Name: "internal", MatchClients: []string{"lan"}, MatchRecursiveOnly: model.Ptr(true), Meta: meta} st.ACLs[2] = &model.ACL{ID: 2, Name: "lan", Elements: []string{"192.168.0.0/16"}, Meta: meta} st.Keys[3] = &model.TSIGKey{ID: 3, Name: "k", Algorithm: "hmac-sha256", Secret: "c2VjcmV0", Meta: meta} st.Zones[4] = &model.Zone{ID: 4, Name: "example.com.", ViewID: 1, Type: "primary", SOA: model.SOA{MName: "ns1.example.com.", Serial: 7}, DefaultTTL: 300, Meta: meta} st.Records[5] = &model.Record{ID: 5, ZoneID: 4, Name: "www.example.com.", Type: "A", TTL: model.Ptr(uint32(60)), Data: "192.0.2.1", Meta: meta} st.Records[6] = &model.Record{ID: 6, ZoneID: 4, Name: "x.example.com.", Type: "A", Data: "192.0.2.2", Meta: meta} st.Options[7] = &model.Option{ID: 7, Scope: model.ScopeOptions, Name: "recursion", Value: "no", Meta: meta} if err := s.InTx(ctx, func(tx *Tx) error { return tx.SaveState(model.NewState(), st) }); err != nil { t.Fatal(err) } got, err := s.LoadState(ctx) if err != nil { t.Fatal(err) } if got.Views[1].Name != "internal" || !*got.Views[1].MatchRecursiveOnly || got.Views[1].MatchClients[0] != "lan" { t.Errorf("view = %+v", got.Views[1]) } if *got.Records[5].TTL != 60 || got.Records[6].TTL != nil { t.Errorf("ttl round trip failed") } if got.Zones[4].SOA.Serial != 7 || got.Settings.Nameservers[0] != "ns1.example.com." { t.Errorf("zone/settings = %+v %+v", got.Zones[4], got.Settings) } if !got.Records[5].CreatedAt.Equal(now) { t.Errorf("time round trip: %v vs %v", got.Records[5].CreatedAt, now) } // Diff save: delete one record, change another. next := got.Clone() delete(next.Records, 6) next.Records[5].Data = "192.0.2.9" if err := s.InTx(ctx, func(tx *Tx) error { return tx.SaveState(got, next) }); err != nil { t.Fatal(err) } again, _ := s.LoadState(ctx) if len(again.Records) != 1 || again.Records[5].Data != "192.0.2.9" { t.Errorf("after diff save: %+v", again.Records) } } func TestUsersTokensSessions(t *testing.T) { ctx := context.Background() s, err := Open(":memory:") if err != nil { t.Fatal(err) } defer s.Close() u := &model.User{Username: "Adam", PasswordHash: "x", Admin: true} if err := s.CreateUser(ctx, u); err != nil { t.Fatal(err) } if err := s.CreateUser(ctx, &model.User{Username: "adam"}); err != ErrConflict { t.Errorf("duplicate username (case-insensitive) = %v", err) } got, err := s.GetUserByName(ctx, "ADAM") if err != nil || got.ID != u.ID || !got.Admin { t.Fatalf("GetUserByName = %+v %v", got, err) } tok := &model.Token{UserID: u.ID, Name: "cli", PublicID: "abc", SecretHash: "h", Grants: []model.Grant{{Resource: "records", Access: "write", Types: []string{"TXT"}}}} if err := s.CreateToken(ctx, tok); err != nil { t.Fatal(err) } gt, err := s.GetTokenByPublicID(ctx, "abc") if err != nil || gt.Username != "Adam" || len(gt.Grants) != 1 || gt.Grants[0].Types[0] != "TXT" { t.Fatalf("token = %+v %v", gt, err) } tok2 := &model.Token{UserID: u.ID, Name: "web", PublicID: "def", SecretHash: "h"} s.CreateToken(ctx, tok2) gt2, _ := s.GetTokenByPublicID(ctx, "def") if gt2.Grants != nil { t.Error("nil grants should stay nil (inherit)") } if err := s.RevokeToken(ctx, tok.ID); err != nil { t.Fatal(err) } gt, _ = s.GetTokenByPublicID(ctx, "abc") if gt.RevokedAt == nil { t.Error("not revoked") } now := time.Now() se := &model.Session{IDHash: "hash", UserID: u.ID, CSRFToken: "c", CreatedAt: now, ExpiresAt: now.Add(time.Hour), LastSeenAt: now} if err := s.CreateSession(ctx, se); err != nil { t.Fatal(err) } gs, err := s.GetSession(ctx, "hash") if err != nil || gs.CSRFToken != "c" { t.Fatalf("session = %+v %v", gs, err) } if err := s.DeleteUser(ctx, u.ID); err != nil { t.Fatal(err) } if _, err := s.GetSession(ctx, "hash"); err != ErrNotFound { t.Error("session not deleted with user") } if _, err := s.GetTokenByPublicID(ctx, "def"); err != ErrNotFound { t.Error("token not deleted with user") } } func TestChangesetsAndAudit(t *testing.T) { ctx := context.Background() s, _ := Open(":memory:") defer s.Close() cs := &model.Changeset{Description: "test", UserID: 1, Username: "adam", Via: "cli", Ops: []model.Op{{Seq: 1, Kind: "record.create", ID: 9}}} if err := s.CreateChangeset(ctx, cs); err != nil { t.Fatal(err) } got, err := s.GetChangeset(ctx, cs.ID) if err != nil || got.Ops[0].Kind != "record.create" || got.Status != model.ChangesetOpen { t.Fatalf("changeset = %+v %v", got, err) } got.Diff = "--- a\n+++ b\n" got.AppliedBy = "adam" if err := s.InTx(ctx, func(tx *Tx) error { return tx.MarkApplied(got) }); err != nil { t.Fatal(err) } if err := s.UpdateChangeset(ctx, got); err != ErrNotFound { t.Errorf("updating an applied changeset = %v", err) } list, _ := s.ListChangesets(ctx, ChangesetFilter{Status: model.ChangesetApplied}) if len(list) != 1 || list[0].Diff != "" { t.Errorf("list = %+v", list) } for _, a := range []string{"auth.login", "auth.login_failed", "changeset.apply"} { s.AddAudit(ctx, &model.AuditEvent{Action: a, Username: "adam", Outcome: "success", ObjectName: "www_example"}) } evs, err := s.ListAudit(ctx, AuditFilter{Action: "auth."}) if err != nil || len(evs) != 2 || evs[0].Action != "auth.login_failed" { t.Errorf("audit = %+v %v", evs, err) } evs, _ = s.ListAudit(ctx, AuditFilter{Search: "www_"}) if len(evs) != 3 { t.Errorf("search found %d", len(evs)) } }