diff --git a/src/server.zig b/src/server.zig index 27cd3c4..b256209 100644 --- a/src/server.zig +++ b/src/server.zig @@ -6123,3 +6123,162 @@ test "ttl_op over the wire: expire, query, persist, and sweep round trip against try server.start(); defer server.stop(); + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); + + // A key with no ttl at all reads back .noExpiry, not .noKey. + try client.set("plain", "v"); + try std.testing.expectEqual(ttlmod.TtlResult.noExpiry, try client.ttlQuery("plain")); + + // expire on a key that does not exist is a real, distinct failure. + try std.testing.expectError(client_mod.ClientError.ServerKeyNotFound, client.ttlExpire("ghost", 60)); + + // expire on an existing key sets a marker the query action can see. + // Real wall-clock time (both `expire` and `query` stamp their own + // `std.time.nanoTimestamp()` server-side) can shave a second off by + // the time the second round trip lands, so accept 59 or 60 rather + // than pinning an exact value. + try client.ttlExpire("plain", 60); + const withTtl = try client.ttlQuery("plain"); + try std.testing.expect(withTtl.seconds == 59 or withTtl.seconds == 60); + + // persist removes the marker again, back to .noExpiry. + try client.ttlPersist("plain"); + try std.testing.expectEqual(ttlmod.TtlResult.noExpiry, try client.ttlQuery("plain")); + + // sweep over a key that is already due for expiry reaps exactly it, + // and a real subsequent get proves the delete was real, not marker-only. + try client.set("doomed", "x"); + try client.ttlExpire("doomed", 0); + std.time.sleep(5 * std.time.ns_per_ms); + const reaped = try client.ttlSweep(); + try std.testing.expectEqual(@as(u64, 1), reaped); + try std.testing.expectEqual(@as(?[]u8, null), try client.get(allocator, "doomed")); + + // querying a key that was never there at all (not merely expired) is + // .noKey, distinct from .noExpiry. + try std.testing.expectEqual(ttlmod.TtlResult.noKey, try client.ttlQuery("never-existed")); +} + +test "ttl_op gates query as readonly and expire as readwrite" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + + const store = authmod.Store.init(&db); + try store.createUser(allocator, "reader", "pw", .readonly, 16); + try store.createUser(allocator, "writer", "pw", .readwrite, 16); + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + server.enableAuth(store); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + + var writer = try client_mod.Client.connect(allocator, server.boundAddress()); + defer writer.close(); + try writer.authenticate("writer", "pw"); + try writer.set("k", "v"); + + var reader = try client_mod.Client.connect(allocator, server.boundAddress()); + defer reader.close(); + try reader.authenticate("reader", "pw"); + + // query is readonly: a readonly connection can run it. + try std.testing.expectEqual(ttlmod.TtlResult.noExpiry, try reader.ttlQuery("k")); + + // expire/persist/sweep all mutate: forbidden for a readonly connection. + try std.testing.expectError(client_mod.ClientError.ServerForbidden, reader.ttlExpire("k", 30)); + try std.testing.expectError(client_mod.ClientError.ServerForbidden, reader.ttlPersist("k")); + try std.testing.expectError(client_mod.ClientError.ServerForbidden, reader.ttlSweep()); + + // The readwrite connection can do all four. + try writer.ttlExpire("k", 30); + try writer.ttlPersist("k"); + _ = try writer.ttlSweep(); +} + +test "server rejects a malformed ttl_op frame with bad_request without crashing" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + try server.start(); + defer server.stop(); + + const stream = try std.net.tcpConnectToAddress(server.boundAddress()); + defer stream.close(); + + try protocol.writeFrame(stream.writer(), @intFromEnum(protocol.Op.ttl_op), "\x09"); + var frame = try protocol.readFrame(allocator, stream.reader()); + defer frame.deinit(); + try std.testing.expectEqual(@intFromEnum(protocol.Status.bad_request), frame.tag); +} + +test "enableTtlSweep periodically reaps due keys nobody ever reads again" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + server.enableTtlSweep(50 * std.time.ns_per_ms); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); + + try client.set("a", "1"); + try client.ttlExpire("a", 0); + + // Long enough for the key to be due and for at least one sweep tick + // (every 50ms) to run past that point - nobody ever reads "a" again, + // so without the background sweeper it would sit there forever (only + // `ttl.getLive`'s lazy path or an explicit sweep would ever catch it). + std.time.sleep(300 * std.time.ns_per_ms); + + try std.testing.expectEqual(@as(?[]u8, null), try client.get(allocator, "a")); + try std.testing.expect(server.ttlSweptTotal.get() >= 1); +} + +test "a server with no ttl sweep interval never runs the ttl sweeper" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); +