1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package session_test
6
7import (
8 "context"
9 "errors"
10 "os"
11 "path/filepath"
12 "testing"
13 "time"
14
15 "git.secluded.site/np/internal/db"
16 "git.secluded.site/np/internal/session"
17 "git.secluded.site/np/internal/testutil"
18)
19
20func TestStoreStartAndArchive(t *testing.T) {
21 ctx := context.Background()
22 database := testutil.OpenDB(t)
23
24 clock := &testutil.SequenceClock{
25 Times: []time.Time{
26 time.Date(2025, time.April, 4, 9, 15, 0, 0, time.FixedZone("A", 3600)),
27 time.Date(2025, time.April, 4, 11, 45, 0, 0, time.FixedZone("B", -3600)),
28 time.Date(2025, time.April, 4, 12, 0, 0, 0, time.UTC),
29 },
30 }
31
32 store := session.NewStore(database, clock)
33
34 workdir := t.TempDir()
35 canonical, hash, err := db.CanonicalizeAndHash(workdir)
36 if err != nil {
37 t.Fatalf("canonicalise temp dir: %v", err)
38 }
39
40 started, err := store.Start(ctx, workdir)
41 if err != nil {
42 t.Fatalf("Start: %v", err)
43 }
44
45 if started.SID == "" {
46 t.Fatalf("expected SID to be populated")
47 }
48 if len(started.SID) != 26 {
49 t.Fatalf("expected ULID SID length 26, got %d", len(started.SID))
50 }
51 if started.DirPath != canonical {
52 t.Fatalf("DirPath mismatch: want %q got %q", canonical, started.DirPath)
53 }
54 if started.DirHash != hash {
55 t.Fatalf("DirHash mismatch: want %q got %q", hash, started.DirHash)
56 }
57 if started.State != session.StateActive {
58 t.Fatalf("expected state active, got %s", started.State)
59 }
60 if started.ArchivedAt != nil {
61 t.Fatalf("expected ArchivedAt to be nil")
62 }
63
64 createdAt := clock.Times[0].UTC()
65 if !started.CreatedAt.Equal(createdAt) || !started.LastUpdatedAt.Equal(createdAt) {
66 t.Fatalf("timestamp mismatch: created=%v updated=%v want=%v", started.CreatedAt, started.LastUpdatedAt, createdAt)
67 }
68
69 err = database.View(ctx, func(txn *db.Txn) error {
70 active, err := txn.Get(db.KeyDirActive(hash))
71 if err != nil {
72 return err
73 }
74 if string(active) != started.SID {
75 t.Fatalf("active SID mismatch: got %s want %s", string(active), started.SID)
76 }
77
78 idxActive, err := txn.Get(db.KeyIdxActive(started.SID))
79 if err != nil {
80 return err
81 }
82 if string(idxActive) != hash {
83 t.Fatalf("idx active value mismatch: got %s want %s", string(idxActive), hash)
84 }
85
86 seq, err := txn.Get(db.KeySessionEventSeq(started.SID))
87 if err != nil {
88 return err
89 }
90 if len(seq) != 8 {
91 t.Fatalf("expected event seq zero buffer length 8, got %d", len(seq))
92 }
93 for i, b := range seq {
94 if b != 0 {
95 t.Fatalf("expected zeroed event seq, byte %d was %d", i, b)
96 }
97 }
98 return nil
99 })
100 if err != nil {
101 t.Fatalf("validate stored keys: %v", err)
102 }
103
104 _, err = store.Start(ctx, workdir)
105 if err == nil {
106 t.Fatalf("expected AlreadyActiveError")
107 }
108 var already session.AlreadyActiveError
109 if !errors.As(err, &already) {
110 t.Fatalf("expected AlreadyActiveError, got %v", err)
111 }
112 if already.Session.SID != started.SID {
113 t.Fatalf("AlreadyActiveError returned unexpected SID: %s", already.Session.SID)
114 }
115
116 childDir := filepath.Join(workdir, "nested")
117 if err := os.MkdirAll(childDir, 0o755); err != nil {
118 t.Fatalf("mkdir nested: %v", err)
119 }
120
121 found, ok, err := store.ActiveByPath(ctx, childDir)
122 if err != nil {
123 t.Fatalf("ActiveByPath child: %v", err)
124 }
125 if !ok {
126 t.Fatalf("expected ActiveByPath to find parent session")
127 }
128 if found.SID != started.SID {
129 t.Fatalf("ActiveByPath returned different SID: %s", found.SID)
130 }
131
132 archived, err := store.Archive(ctx, started.SID)
133 if err != nil {
134 t.Fatalf("Archive: %v", err)
135 }
136 if archived.State != session.StateArchived {
137 t.Fatalf("expected archived state, got %s", archived.State)
138 }
139 wantArchivedAt := clock.Times[1].UTC()
140 if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(wantArchivedAt) {
141 t.Fatalf("ArchivedAt mismatch: got %v want %v", archived.ArchivedAt, wantArchivedAt)
142 }
143 if !archived.LastUpdatedAt.Equal(wantArchivedAt) {
144 t.Fatalf("LastUpdatedAt mismatch: got %v want %v", archived.LastUpdatedAt, wantArchivedAt)
145 }
146
147 err = database.View(ctx, func(txn *db.Txn) error {
148 if exists, err := txn.Exists(db.KeyDirActive(hash)); err != nil {
149 return err
150 } else if exists {
151 t.Fatalf("expected dir active key to be removed")
152 }
153 if exists, err := txn.Exists(db.KeyIdxActive(started.SID)); err != nil {
154 return err
155 } else if exists {
156 t.Fatalf("expected idx active key to be removed")
157 }
158
159 tsHex := db.Uint64Hex(uint64(wantArchivedAt.UnixNano()))
160 idxVal, err := txn.Get(db.KeyIdxArchived(tsHex, started.SID))
161 if err != nil {
162 return err
163 }
164 if string(idxVal) != hash {
165 t.Fatalf("idx archived value mismatch: got %s want %s", string(idxVal), hash)
166 }
167
168 dirArchivedKey := db.KeyDirArchived(hash, tsHex, started.SID)
169 if exists, err := txn.Exists(dirArchivedKey); err != nil {
170 return err
171 } else if !exists {
172 t.Fatalf("expected dir archived key to exist")
173 }
174 return nil
175 })
176 if err != nil {
177 t.Fatalf("validate archival keys: %v", err)
178 }
179
180 repeat, err := store.Archive(ctx, started.SID)
181 if err != nil {
182 t.Fatalf("Archive second time: %v", err)
183 }
184 if repeat.State != session.StateArchived {
185 t.Fatalf("expected archived state on second archive")
186 }
187
188 if _, found, err := store.ActiveByPath(ctx, workdir); err != nil {
189 t.Fatalf("post-archive ActiveByPath error: %v", err)
190 } else if found {
191 t.Fatalf("ActiveByPath should not find archived session")
192 }
193}