1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6 "bytes"
7 "context"
8 "strings"
9 "testing"
10 "unicode/utf8"
11
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15)
16
17func TestMirrorRoundTripsAndDelete(t *testing.T) {
18 ctx := context.Background()
19 s := openStore(t, t.TempDir())
20 repo := name(t, "mirror")
21 g, err := s.GetGlobal(ctx)
22 require.NoError(t, err)
23 require.Equal(t, domain.MirrorGlobalConfig{}, g)
24 _, err = s.GetRepoConfig(ctx, repo)
25 require.ErrorIs(t, err, domain.ErrNotFound)
26 _, err = s.GetStatus(ctx, repo)
27 require.ErrorIs(t, err, domain.ErrNotFound)
28 _, err = s.GetSnapshot(ctx, repo)
29 require.ErrorIs(t, err, domain.ErrNotFound)
30 _, _, err = s.GetMirrorKey(ctx)
31 require.ErrorIs(t, err, domain.ErrNotFound)
32 require.NoError(t, s.DeleteRepoConfig(ctx, repo))
33 require.ErrorIs(t, s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo}), domain.ErrNotFound)
34 require.ErrorIs(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo}), domain.ErrNotFound)
35 require.ErrorIs(t, s.PutSnapshot(ctx, repo, domain.MirrorSnapshot{}), domain.ErrNotFound)
36 require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
37 g = domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.org:{repo}", Cron: "*/5 * * * *", AllBranches: true, UpdatedAt: created}
38 require.NoError(t, s.PutGlobal(ctx, g))
39 gg, err := s.GetGlobal(ctx)
40 require.NoError(t, err)
41 require.Equal(t, g, gg)
42 all := false
43 c := domain.MirrorRepoConfig{Repo: repo, URL: "git@example.org:override", Cron: "0 * * * *", AllBranches: &all, Excluded: true, UpdatedAt: created}
44 require.NoError(t, s.PutRepoConfig(ctx, c))
45 cc, err := s.GetRepoConfig(ctx, repo)
46 require.NoError(t, err)
47 require.Equal(t, c, cc)
48 cs, err := s.ListRepoConfigs(ctx)
49 require.NoError(t, err)
50 require.Equal(t, []domain.MirrorRepoConfig{c}, cs)
51 c.AllBranches = nil
52 require.NoError(t, s.PutRepoConfig(ctx, c))
53 cc, err = s.GetRepoConfig(ctx, repo)
54 require.NoError(t, err)
55 require.Nil(t, cc.AllBranches)
56 st := domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, Reason: "retry", Detail: "network", ConsecutiveFailures: 3, LastAttemptAt: created, LastSuccessAt: created, NextRetryAt: created, PausedAt: created, UpdatedAt: created}
57 require.NoError(t, s.PutStatus(ctx, st))
58 ss, err := s.GetStatus(ctx, repo)
59 require.NoError(t, err)
60 require.Equal(t, st, ss)
61 statuses, err := s.ListStatus(ctx)
62 require.NoError(t, err)
63 require.Equal(t, []domain.MirrorStatus{st}, statuses)
64 id, err := domain.ParseObjectID("0123456789012345678901234567890123456789")
65 require.NoError(t, err)
66 snap := domain.MirrorSnapshot{URL: c.URL, AllBranches: true, At: created, Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: id}}}
67 require.NoError(t, s.PutSnapshot(ctx, repo, snap))
68 got, err := s.GetSnapshot(ctx, repo)
69 require.NoError(t, err)
70 require.Equal(t, snap, got)
71 sealed := []byte("opaque sealed seed")
72 require.NoError(t, s.PutMirrorKey(ctx, sealed, created, true))
73 require.ErrorIs(t, s.PutMirrorKey(ctx, []byte("other"), created, true), domain.ErrAlreadyExists)
74 blob, at, err := s.GetMirrorKey(ctx)
75 require.NoError(t, err)
76 require.Equal(t, sealed, blob)
77 require.Equal(t, created, at)
78 raw, ok := s.RawMirrorKey(ctx)
79 require.True(t, ok)
80 require.False(t, bytes.Equal(raw, sealed), "raw record is a protobuf envelope, not bare key material")
81 require.NoError(t, s.PutMirrorKey(ctx, []byte("rotated"), created, false))
82 blob, _, err = s.GetMirrorKey(ctx)
83 require.NoError(t, err)
84 require.Equal(t, []byte("rotated"), blob)
85 require.NoError(t, s.Delete(ctx, repo))
86 _, err = s.GetRepoConfig(ctx, repo)
87 require.ErrorIs(t, err, domain.ErrNotFound)
88 _, err = s.GetStatus(ctx, repo)
89 require.ErrorIs(t, err, domain.ErrNotFound)
90 _, err = s.GetSnapshot(ctx, repo)
91 require.ErrorIs(t, err, domain.ErrNotFound)
92 _, _, err = s.GetMirrorKey(ctx)
93 require.NoError(t, err)
94 gg, err = s.GetGlobal(ctx)
95 require.NoError(t, err)
96 require.Equal(t, g, gg)
97 require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
98 _, err = s.GetStatus(ctx, repo)
99 require.ErrorIs(t, err, domain.ErrNotFound)
100}
101
102func TestMirrorStatusInvalidUTF8IsRepairedNotPanicking(t *testing.T) {
103 ctx := context.Background()
104 s := openStore(t, t.TempDir())
105 repo := name(t, "mirror")
106 require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
107 detail := strings.Repeat("é", 2048) + "\xff tail"
108 require.NotPanics(t, func() {
109 require.NoError(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Detail: detail, Reason: "bad \xfe"}))
110 })
111 got, err := s.GetStatus(ctx, repo)
112 require.NoError(t, err)
113 require.True(t, utf8.ValidString(got.Detail))
114 require.True(t, utf8.ValidString(got.Reason))
115 require.Contains(t, got.Detail, "? tail")
116 // Other records refuse invalid text instead of panicking.
117 require.NotPanics(t, func() {
118 err := s.PutGlobal(ctx, domain.MirrorGlobalConfig{URLTemplate: "\xff"})
119 require.ErrorIs(t, err, domain.ErrInvalid)
120 })
121 require.NotPanics(t, func() {
122 err := s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo, URL: "\xff"})
123 require.ErrorIs(t, err, domain.ErrInvalid)
124 })
125}